
    5j]                        d Z ddlZddlZddlZddlZddlmZmZ ddlmZ ddl	Z	ddl
ZdZdZdZdZd	Zd
ZdZdZdZeeeeeeeeef	Z eeeh      ZdZdZeefZedediZ ee      j;                         j<                  Zedz  Z edz  Z!edz  Z"edz  Z#edz  Z$dZ%ejL                  jO                  dd      Z(ejL                  jO                  dd      Z)ejL                  jO                  dd      Z*e*rde* ndZ+ejL                  jO                  dd      Z,dgZ- ej\                         Z/ddl0Z0 e0jb                  d      Z2d dddd!d"Z3d# Z4d]d$Z5d% Z6d& Z7d' Z8d( Z9d) Z:d* Z;d+Z<d, Z=d- Z>d. Z?d/ Z@d0 ZAed1ed2ed3ed4ed5ed6ed7ed8ed9i	ZBd: ZCd; ZDd< ZEd= ZFd> ZGd? ZHd@ddAdBZIdC ZJdd@ddDdEZKdFZLdGZMdHZNdIZOdJZPdKZQeMeNeOePeQfZReMdLeNdMeOdNePdOeQdPiZSeMdQeNdReOdSePdTeQdUiZTdVdWdXZUdY ZVdZ ZWd[ ZXdd@ddDd\ZYy)^u  
notifications.py — Event-driven notification dispatch core for Togen (DVI-333).

Foundation module for the Togen Notifications System (DVI-332). Provides:

  * Namespaced event constants (EVENT_*).
  * notify(event, context) — resolves personal recipients, renders the branded
    HTML template for the event, and sends via Microsoft Graph on a background
    thread (mirrors the existing fire-and-forget send pattern in app.py).
  * send_html_email(to, subject, html) — shared Graph sendMail helper, reused by
    app.py callers (e.g. the News email path).
  * render_email_shell(...) — the branded, Outlook-safe table HTML shell.
  * A file-based idempotency log (notification_log.json) so recurring events
    never double-send to the same recipient.

Recipient resolution OR-merges two independent sources, de-duplicated by email:

  1. Per-WO opt-in (individual): the submitting user who checked "Enable
     notifications about this work order" — carried in the WO JSON sidecar under
     ``_notify``. Notifies only that submitter, about only that one work order.
  2. Admin-wide grid prefs: the 3x5 User Settings / WO Admin "Settings" grid is
     a Work Order Admin preference that applies to ALL work orders submitted by
     ANY user. Every user whose grid enables (WO type x event) is notified,
     regardless of who submitted the work order. The grid save route is
     admin-gated in app.py, so only WO Admins can persist these prefs.

This module is intentionally self-contained (it does not import app.py) so it
can sit beneath app.py in the import graph and be imported/tested in isolation.
It reads the same env vars and on-disk JSON layout that app.py uses.
    N)datetimetimezone)Pathzworkorder.createdzworkorder.status.in_progresszworkorder.status.completedzworkorder.status.cancelledzworkorder.recurring.due_soonzworkorder.recurring.overduezworkorder.evh.awaiting_approvalzworkorder.evh.finalizedzworkorder.evh.rejectedzscada.data.stalezscada.sensor.out_of_range
stale_dataout_of_rangeuser_settingsznotification_log.jsonzwor_primary.jsonzwor_policy.jsonzrpm_approval.jsonwo_notificationsAZURE_CLIENT_ID AZURE_CLIENT_SECRETAZURE_TENANT_IDz"https://login.microsoftonline.com/AZURE_MAIL_FROMz$https://graph.microsoft.com/.defaultztogen.notificationsNotification)subtitle	meta_htmlcta_htmlfooter_htmlc                0    dj                  || ||||      S )zWrap content in the branded, Outlook-safe (table-based) HTML email shell.

    Same markup as app.py's news shell, generalized via the ``subtitle`` slot so
    every notification type can share one branded template.
    a7  <!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head><body style="margin:0;padding:0;background:#f3f4f6;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f3f4f6;padding:24px 0;"><tr><td align="center"><table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;width:100%;background:#ffffff;border-radius:12px;overflow:hidden;border:1px solid #e5e7eb;font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;"><tr><td style="background:#0077A8;background:linear-gradient(135deg,#00B4D8 0%,#0077A8 100%);padding:22px 28px;"><span style="font-size:26px;font-weight:700;color:#ffffff;letter-spacing:-0.5px;">togen</span><span style="display:inline-block;width:7px;height:7px;border-radius:50%;background:#a7e8f7;margin-left:2px;vertical-align:super;"></span><div style="font-size:12px;color:#d4f1f9;margin-top:2px;">{subtitle}</div></td></tr><tr><td style="padding:28px;color:#374151;font-size:15px;"><h1 style="margin:0 0 6px;font-size:22px;color:#111827;font-weight:700;line-height:1.3;">{title}</h1>{meta}<hr style="margin:16px 0;border:none;border-top:1px solid #e5e7eb;">{body}{cta}</td></tr><tr><td style="padding:0 28px 24px;">{footer}</td></tr></table></td></tr></table></body></html>)r   titlemetabodyctafooterformat)r   	body_htmlr   r   r   r   s         $/var/www/html/togen/notifications.pyrender_email_shellr      s,     	3B fhe))+  /E#/    c                  d   t         rt        rt        st        j	                  d       y	 t        j                  t         t        t              } | j                  t              }|j                  d      }|st        j                  d       |S # t        $ r t        j                  d       Y yw xY w)zBAcquire an app-only Graph token. Returns access token str or None.z9Graph token unavailable: Azure credentials not configuredN)	authorityclient_credential)scopesaccess_tokenz,Graph token request returned no access_tokenzGraph token acquisition error)_AZ_CLIENT_ID
_AZ_SECRET_AZ_AUTHORITY_logwarningmsalConfidentialClientApplicationacquire_token_for_client_GRAPH_SCOPESgeterror	Exception	exception)
app_clientresulttokens      r   _acquire_graph_tokenr5      s    j]PQ
77]jR
44M4J

>*JJEF 67s   A#B B/.B/c           	      x   t         st        j                  d       yt        | t              r| g}n| D cg c]  }|s|	 }}|syt               }|sy|d|d|D cg c]  }dd|ii
 c}d}|r.|D cg c]  }d|d	   |j                  d
d      |d   d! c}|d<   |dd}		 t        j                  dt          dd| dd|	d      }
|
j                  dvr/t        j                  d|
j                  |
j                  dd        yyc c}w c c}w c c}w # t        $ r t        j                  d       Y yw xY w)ap  Low-level Microsoft Graph sendMail. Synchronous. Returns True on accept.

    ``to`` may be a single address or an iterable of addresses. ``attachments``
    is an optional list of ``{"name", "contentType", "contentBytes"}`` dicts
    where ``contentBytes`` is the base64-encoded file payload (Graph
    fileAttachment shape). From address is ``AZURE_MAIL_FROM``.
    z*Cannot send email: AZURE_MAIL_FROM not setFHTML)contentTypecontentemailAddressaddress)subjectr   toRecipientsz#microsoft.graph.fileAttachmentnamer8   zapplication/octet-streamcontentBytes)z@odata.typer>   r8   r?   attachmentsfalse)messagesaveToSentItemsz'https://graph.microsoft.com/v1.0/users/z	/sendMailzBearer zapplication/json)AuthorizationzContent-Type<   )headersjsontimeout)      zsendMail failed (%s): %sNi,  TzsendMail error)r   r(   r)   
isinstancestrr5   r.   _http_requestspoststatus_coder/   textr0   r1   )tor<   htmlr@   	addressesar4   rB   att
graph_bodyresps              r   _send_graph_mailrX      s{    AB"cD	 "(1aQ(	( "E  &48CLMa.9a.9MG
  #	"
   AF"ww!#= ? #N 3	"
 %AJ""5o5FiP#*5'!2 2 
 :-JJ143C3CTYYtPS_UM ) N	"
2  '(s)   D	D	D4$D$A#D D98D9c                     t        | ||      S )a  Send a branded HTML email via Microsoft Graph sendMail. Synchronous.

    ``to`` may be a single address string or an iterable of addresses (all are
    placed on a single message's toRecipients list). From address is
    ``AZURE_MAIL_FROM``. Returns True on a 200/202 accept, False otherwise.

    This is the shared helper extracted from app.py's admin/news send paths;
    callers that need fire-and-forget behaviour should run it on a thread.
    )rX   )rQ   r<   rR   s      r   send_html_emailrZ     s     B..r   c          	      r    t        j                  |      j                  d      }t        | |||||dg      S )u  Send an HTML email with a single file attachment via Graph sendMail.

    ``content`` is the raw file bytes; it is base64-encoded into a Graph
    fileAttachment. Synchronous; returns True on a 200/202 accept. Added for the
    SCADA report scheduler (DVI-486), which emails generated XLSX reports —
    ``send_html_email`` is HTML-only.

    Graph's simple sendMail caps total message size around 4 MB, so callers
    should guard large attachments before invoking this (the report scheduler
    skips XLSX payloads over 3 MB).
    ascii)r>   r8   r?   )r@   )base64	b64encodedecoderX   )rQ   r<   rR   filenamer9   content_typeencodeds          r   send_email_with_attachmentrc     sF     w'..w7G
GT'#
  r   c                 b    t        | t              r| j                         j                         S dS Nr   )rK   rL   striplower)addrs    r   _normalize_emailri   5  s%    #-dC#84::<@b@r   c              #     K   | r|sy	 t        t        j                  d            }|D ]  }	 t	        j
                  |j                               }t        |t              s8|j                  t              }t        |t              s^|j                  |       xs i }|j                  |      s|j                  d      xs |j                  }|s|  y# t        $ r Y yw xY w# t        j                  t        f$ r Y w xY ww)a|  Yield the email of every user whose grid enables (wor_type x event).

    Scans every ``USER_SETTINGS_DIR/*.json``. The grid is admin-wide: a match
    means that user wants this notification for ALL work orders of this type,
    regardless of submitter. Because the grid save route is admin-gated in
    app.py, only WO Admins can have a ``wo_notifications`` block persisted, so
    this effectively yields the WO Admins subscribed to the cell.

    Each user's email is read from the stored ``email`` field when present,
    falling back to the settings filename stem (which equals the lowercased
    email for normal addresses).
    N*.jsonemail)sortedUSER_SETTINGS_DIRglobOSErrorrG   loads	read_textJSONDecodeErrorrK   dictr.   USER_SETTINGS_NOTIFY_KEYstem)wor_typeeventfilesfpsettingsprefs
type_prefsrl   s           r   _iter_grid_recipientsr~   9  s      (--h78  	zz",,.1H (D)56%&YYx(.B
>>% LL)4RWWE  
 $$g. 		sW   DC D#C&AD.!DD	C# D"C##D&D?DDDc              #     K   | rt         j                         sy	 t        j                  t         j	                               }t        |t              sy|j                  |       }t        |t              r.|D ](  }t        |t              s|j                         s%| * yy# t        j
                  t        f$ r Y yw xY ww)zYield every email on the Primary list for this WO type (DVI-405).

    Primary recipients receive ALL workorder events for their WO type,
    regardless of submitter or per-event grid prefs.
    N)WOR_PRIMARY_FILEis_filerG   rq   rr   rs   rp   rK   rt   r.   listrL   rf   )rw   dataemailses       r   _iter_primary_recipientsr   ]  s      +335zz*4467 dD!XXhF&$ 	A!S!aggi	     '* s5   C'B# AC
CC#B?<C>B??Cc                 ^   | r|rt         j                         sy	 t        j                  t         j	                               }t        |t              sy|j                  |       }t        t        |t              xr |j                  |            S # t        j
                  t        f$ r Y yw xY w)zFTrue if the company Policy grid enforces (wor_type x event) (DVI-597).F)WOR_POLICY_FILEr   rG   rq   rr   rs   rp   rK   rt   r.   bool)rw   rx   r   r}   s       r   _policy_enforcesr   r  s    '>'>'@zz/3356 dD!(#J
:t,F1FGG   '* s   'B B,+B,scada_notificationsc                 p   g }t               }| s|S 	 t        t        j                  d            }|D ]  }	 t        j                  |j                               }t        |t              s8|j                  t              }t        |t              r|j                  |       so|j                  d      xs |j                  }t        |      }|s||vs|j                  |       |j!                  |j#                                 |S # t        $ r |cY S w xY w# t
        j                  t        f$ r Y w xY w)a  Resolve de-duplicated emails of users who opted into a SCADA pref.

    Scans every ``USER_SETTINGS_DIR/*.json`` for a truthy
    ``scada_notifications[pref_key]`` (e.g. ``"stale_data"`` /
    ``"out_of_range"``). Email is read from the stored ``email`` field when
    present, falling back to the settings filename stem.

    Returns a list of original-cased email strings, order-stable, no duplicates.
    rk   rl   )setrm   rn   ro   rp   rG   rq   rr   rs   rK   rt   r.   SCADA_NOTIFY_KEYrv   ri   addappendrf   )	pref_keyorderedseenry   rz   r{   r|   rl   norms	            r   resolve_scada_recipientsr     s    G5D(--h78  *	zz",,.1H (D)-.%&eii.AW%0&D$HHTNNN5;;=)* N#  
 $$g. 		s"   D #DDDD54D5c                   	 g t               		fd}|j                  d      xs i }t        |t              r(|j                  d      r ||j                  d             |j                  d      }t	        ||       D ]
  } ||        t        |      D ]
  } ||        t        ||       rg|j                  d      xs i }|j                  d      xs7 |j                  d      xs$ |j                  d      xs |j                  d      } ||       | t        t        t        fv rD|j                  d      xs g D ]
  } ||        |j                  d	      xs g D ]
  } ||        S )
u  Resolve the de-duplicated personal recipients for an event.

    Three independent (OR-merged) sources, de-duplicated by lowercased email:

      Source A — per-WO opt-in (individual): ``context["notify"]`` (the sidecar
        ``_notify`` block) of shape ``{"enabled": bool, "email": str}``.
        Notifies only that submitter, about only this one work order.
      Source B — admin-wide grid prefs: every user whose User Settings / WO
        Admin "Settings" grid enables (``context["wor_type"]`` x event),
        regardless of who submitted the work order.
      Source C — Primary list: every email flagged Primary for
        ``context["wor_type"]`` receives all events for that type (DVI-405).
      Source D — Policy-enforced grid: when the company Policy grid enables
        (``context["wor_type"]`` x event), the requesting user of the work
        order is notified as a matter of policy, regardless of opt-in (DVI-597).

    Returns a list of original-cased email strings, order-stable, no duplicates.
    c                     t        |       }|r6|vr1j                  |       j                  | j                                y y y Nri   r   r   rf   )rh   r   r   r   s     r   _addz resolve_recipients.<locals>._add  s;    %D$HHTNNN4::<( %4r   notifyenabledrl   rw   requestor_emailsubmitter_emailevh_dept_emailsevh_recipients)
r   r.   rK   rt   r~   r   r   EVENT_WO_EVH_AWAITINGEVENT_WO_EVH_FINALIZEDEVENT_WO_EVH_REJECTED)
rx   contextr   notify_blockrw   rl   	requestorrh   r   r   s
           @@r   resolve_recipientsr     s|   & G5D) ;;x(.BL,%,*:*:9*E\g&' {{:&H&x7 U
 *(3 U %({{8,2!%%&78 -$((1-$56-  G, 	 	Y &(>&( ([[!239r 	DJ	[[!128b 	DJ	 Nr   c                      t         j                         sg S 	 t        j                  t         j	                               } t        | t              r| S g S # t        j                  t        f$ r g cY S w xY wr   )	NOTIFICATION_LOG_FILEr   rG   rq   rr   rK   r   rs   rp   )r   s    r   	_load_logr     sc     ((*	zz/99;<!$-t525  '* 	s   8A A A10A1c                    | |t        |      t        j                  t        j                        j                         d}t        5  t               }|j                  |       	 t        j                  t        j                  |d             ddd       y# t        $ r t        j                  d       Y )w xY w# 1 sw Y   yxY w)z>Append a {wor_id, event, recipient, sent_at} entry to the log.)wor_idrx   	recipientsent_at   )indentz'Failed to persist notification_log.jsonN)ri   r   nowr   utc	isoformat	_log_lockr   r   r   
write_textrG   dumpsrp   r(   r1   )r   rx   r   entrylogs        r   _record_sentr     s     %i0<<-779	E 
 Fk

5	F!,,TZZA-FG	F F
  	FNNDE	FF Fs*   B:#*BB74B:6B77B::Cc                     t        |      }t        5  t               D ]J  }|j                  d      | k(  s|j                  d      |k(  s-|j                  d      |k(  sB ddd       y 	 ddd       y# 1 sw Y   yxY w)zATrue if (wor_id, event, recipient) was previously logged as sent.r   rx   r   NTF)ri   r   r   r.   )r   rx   r   r   r   s        r   already_sentr     sw    I&D	  	Ah6)aeeGn.Ek*d2	 	
 
 s"   "A4A4
A4A4)A44A=)zWork Order {ref} submittedz:Your work order has been submitted and is awaiting review.)zWork Order {ref} is in progressz$Work on your work order has started.)zWork Order {ref} completedz)Your work order has been marked complete.)zWork Order {ref} cancelledz#Your work order has been cancelled.)z#Recurring Work Order {ref} due soonz1A recurring work order you submitted is due soon.)z"Recurring Work Order {ref} overduez4A recurring work order you submitted is now overdue.)u(   RtS Approval needed — Work Order {ref}zXYour department's sign-off is required before this equipment can be returned to service.)u5   Equipment released to production — Work Order {ref}zwAll required departments have approved the Equipment Validation Handoff. The equipment has been released to production.)u*   RtS Approval rejected — Work Order {ref}z_A return-to-service approval was rejected. The work order has been reopened for further action.c                     | j                  d      }|r	 dt        |      dS | j                  d      }|rt	        |      S dS # t        t        f$ r d| cY S w xY w)z6Human-friendly work-order reference for subjects/body.
wor_number#05dr   r   )r.   int	TypeError
ValueErrorrL   )r   numwids      r   _wor_referencer   J  sn    
++l
#C
	s3xn%% ++h
C3s8"" :& 	se9	s   A AAc                 x    t        |       j                  dd      j                  dd      j                  dd      S )N&z&amp;<z&lt;>z&gt;)rL   replace)values    r   _escr   V  s1    JWS'"773#7V8LNr   c                  f    t         j                  j                  d      xs dj                  d      S )z5Public Togen base URL for building in-app deep links.TOGEN_BASE_URLzhttps://togen.icastinc.com/)osenvironr.   rstrip r   r   	_base_urlr   [  s(    JJNN+, ,+VVC[9r   c                 
   ddl m} | j                  d      }|dv ry	 t        |      d}t                d ||       }dj                  t        |      t        |      	      S # t        t
        f$ r t        |      }Y Vw xY w)
a  A deep-link button into WO Admin focused on this work order (DVI-1174).

    Links to ``?open_wo=<number>`` which the frontend resolves by opening the WO
    Admin tool and selecting the WO (approver-only users land on the Approvals
    tab). SSO-gated; recipients without WO Admin access land on the Togen home
    (graceful). Returns "" when the context carries no work-order number.
    r   )quoter   re   r   r   z
/?open_wo=aG  <div style="margin:18px 0 2px;"><a href="{link}" style="display:inline-block;background:#0077A8;color:#ffffff;text-decoration:none;font-size:14px;font-weight:600;padding:10px 20px;border-radius:8px;">{label}</a><p style="margin:8px 0 0;font-size:12px;color:#9ca3af;">Opens this work order in Togen (sign-in required).</p></div>)linklabel)
urllib.parser   r.   r   r   r   rL   r   r   r   )r   r   r   r   r4   r   s         r   _wo_cta_htmlr   a  s     #
++l
#C
js8C. k]*U5\N3D	G f$t*DKf01 z" Cs   A% %BBc           	         t         j                  |       }|sy|\  }}t        |      }|j                  |xs d      j	                         }g }dD ]M  \  }}	|j                  |	      }
|
s|j                  dj                  t        |      t        |
                   O |rddj                  |      z   dz   nd}|j                  d	      }|rd
j                  t        |            nd}dj                  t        |      ||      }d}| t        k(  rdnd}t        ||      }t        t        |      |d||      }||fS )zGRender (subject, html) for an event given its context. None if unknown.Nr   )ref))	Requestorrequestor_name)Locationlocation)Buildingbuilding)Urgencyurgency)zDesired completiondesired_completion_date<tr><td style="padding:4px 12px 4px 0;color:#6b7280;font-size:13px;white-space:nowrap;vertical-align:top;">{lbl}</td><td style="padding:4px 0;color:#111827;font-size:13px;">{val}</td></tr>lblvalU<table role="presentation" cellpadding="0" cellspacing="0" style="margin:8px 0 4px;"></table>descriptionzd<p style="margin:14px 0 0;color:#374151;font-size:14px;line-height:1.6;white-space:pre-wrap;">{}</p>zL<p style="margin:0;font-size:15px;line-height:1.6;">{intro}</p>{table}{desc})introtabledescz<hr style="margin:8px 0 14px;border:none;border-top:1px solid #e5e7eb;"><p style="font-size:12px;color:#9ca3af;text-align:center;margin:0;line-height:1.6;">You are receiving this because you enabled notifications for this work order.</p>zView & approve in TogenzOpen this work order in TogenzWork Order Update)r   r   r   r   r   )_EVENT_COPYr.   r   r   rf   r   r   joinr   r   r   )rx   r   copysubject_tmplr   r   r<   rowsr   keyr   detail_tabler   	desc_htmlr   r   	cta_labelr   rR   s                      r   render_event_emailr   |  sr   ??5!DL%

!C!!ciR!0668GDK @
s
 kk#KK 4;DI>	@@ 	%')wwt}	57A	B 
 ;;}%D 	88>tDz8J 	T%[9U 
	1  /47L.L*5 GY/H7m$D D=r   c                     t        | |      }|st        j                  d|        y|\  }}|j                  d      }| t        v }|D ]1  }	|r|rt        || |	      r ||	||      }
|
s"|s%t        || |	       3 y)z:Send to each recipient once, honoring the idempotency log.z notify: no template for event %sNr   )r   r(   r/   r.   RECURRING_EVENTSr   r   )rx   r   
recipientssenderrenderedr<   rR   r   guardr   oks              r   	_dispatchr    s    !%1H

5u=MGT[["F%%E 3	VVUI FIw-&	23r   T)
backgroundr   c                    | t         vrt        j                  d|        g S t        | |      }|sg S |xs t        }|r2t        j                  t        | |||fd      }|j                          |S t        | |||       |S )u  Resolve recipients, render the event template, and send.

    Args:
        event: one of the EVENT_* constants.
        context: dict carrying recipient-resolution inputs (``notify``,
            ``submitter_email``/``email``, ``wor_type``) and template fields
            (``wor_id``, ``wor_number``, ``requestor_name``, ``description``, …).
        background: when True (default) dispatch on a daemon thread (mirrors the
            existing fire-and-forget pattern); when False, dispatch inline
            (useful for tests / synchronous callers).
        sender: callable (to, subject, html) -> bool used to actually send;
            defaults to :func:`send_html_email`. Injectable for tests.

    Returns the resolved recipient list (so callers/tests can assert on it).
    znotify: unknown event %rT)targetargsdaemon)	WORKORDER_EVENTSr(   r/   r   rZ   	threadingThreadr  start)rx   r   r  r   r   sendts          r   r   r     s      $$

-u5	#E73J	$_DE7J#EdT		  	%*d3r   c           
         | t         k(  r|j                  d      }d}|j                  d      }|j                  d      }g }|+|j                  ddj                  t	        |            f       |+|j                  ddj                  t	        |            f       |r|j                  d	t	        |      f       d
}d}n| t
        k(  r|j                  d      xs |j                  d      xs d}	|j                  d      xs d}
d}|j                  d      dk(  r"|
rdt	        |
      z   nd}|j                  d      }|j                  d      }|j                  d      }|j                  d      }|j                  d      }|j                  d      }dj                  |	      }dt	        |	      fddj                  t	        |      |t	        |            fddj                  t	        |      |t	        |            fg}|9|7|j                  d d!j                  t	        |      |t	        |      |      f       d"}n%|j                  d#      }|j                  d$      }|j                  d%      }|d&vr:|d&vr6d'j                  t	        |      t	        |      |
rdt	        |
      z   nd      }nb|d&vr,d(j                  t	        |      |
rdt	        |
      z   nd      }n2|d&vr,d)j                  t	        |      |
rdt	        |
      z   nd      }nd*}d+j                  |	      }dt	        |	      fd,d-j                  t	        |      |
rdt	        |
      z   nd      fd.|fg}d/}nydj                  d0 |D              }|rd1|z   d2z   nd}d3j                  t	        |      |4      }d5}t        t	        |      |d6|7      }||fS )8uk  Render (subject, html) for a SCADA event. None for an unknown event.

    Context keys:
      stale event   — ``stale_after_minutes`` (int), ``last_reading_time`` (str
        or None), ``age_minutes`` (int or None).
      out-of-range  — ``sensor`` (display name), ``value`` (str), ``low`` /
        ``high`` (str/num or None), ``unit`` (str, optional).
    stale_after_minutesz!SCADA alert: no new data receivedlast_reading_timeage_minutesNzStale-after thresholdz{} minzTime since last readingzLast readingu}   The SCADA collector has not received a new reading within the configured window. Data may be stale — check the data source.zSCADA data is stalesensorr   Sensorunitr   zSensor out of rangereasonrate_of_change 
rate_deltarate_windowrate_max_delta	rate_span	rate_fromrate_toz!SCADA alert: {} changing too fastzChange observedz{}{} in {} minzAllowed changeu   ≤ {}{} within {} minReadingsu   {}{} → {}{}ziA monitored SCADA sensor changed faster than its allowed rate of change in the most recent data received.r   lowhighre   u   {} – {}{}u   ≥ {}{}u   ≤ {}{}u   —zSCADA alert: {} out of rangezCurrent valuez{}{}zConfigured rangezKA monitored SCADA sensor has reported a value outside its configured range.c              3   Z   K   | ]#  \  }}d j                  t        |      |       % ywr   r   N)r   r   .0r   r   s      r   	<genexpr>z%render_scada_email.<locals>.<genexpr>B  s9      E 4<3	 49#.	/Es   )+r   r   F<p style="margin:0;font-size:15px;line-height:1.6;">{intro}</p>{table}r   r   z<hr style="margin:8px 0 14px;border:none;border-top:1px solid #e5e7eb;"><p style="font-size:12px;color:#9ca3af;text-align:center;margin:0;line-height:1.6;">You are receiving this because you enabled SCADA notifications in your Togen settings.</p>zSCADA Alertr   r   r   r   )EVENT_SCADA_STALEr.   r   r   r   EVENT_SCADA_OUT_OF_RANGEr   r   )rx   r   minsr<   lastager   r   r   r  r  usufdeltawindowlimitspanfrmrQ   r   r  r  rngrow_htmlr   r   r   rR   s                              r   render_scada_emailr5    s    !!{{015{{./kk-(KK0(//$t*2MNO?KK2HOODI4NOPKKd45S%	*	*X&J'++g*>J({{6"(b%;;x $44 *.C$t*$2DKK-E[[/FKK 01E;;{+D++k*CY'B9@@HG4<("$4$;$;KtDz%3 4!#;#B#BKtF|$5 6	D 2>Z)?)?ItT"Xt*5 6 7HE KK(E++e$C;;v&D*$Z)?#**49d4j?C#T
*:MJ& ''S	C$t*4DSUVZ' ''T
$S4:5ETVW4;;FCG4<( &--UFJtDz1APR#T U#S)	D)E ww E @D	E EH AI	%'/	02<	=NP 
 	Q	d5k	6 
	4  5kYK9D D=r   )r   r  r   c                ,  	 | t         vrt        j                  d|        g S t        t        |          sg S t        | |      }|sg S |\  	|xs t        	fd}|r't        j                  |d      j                          S  |        S )u*  Send a SCADA monitoring email to opted-in users.

    Recipients default to everyone whose ``scada_notifications`` enables the
    pref mapped to ``event`` (see :data:`SCADA_EVENT_PREF`); pass an explicit
    ``recipients`` list to override. Unlike :func:`notify`, idempotency is the
    caller's responsibility — the SCADA collector only calls this on a state
    transition (entering staleness / a sensor going out of range), so emails
    are not re-sent every poll.

    Returns the resolved recipient list (so callers/tests can assert on it).
    znotify_scada: unknown event %rc                  (    D ]  }  |         y r   r   r   rR   r   r  r<   s    r   _runznotify_scada.<locals>._runv      # 	+IGT*	+r   Tr  r  )
SCADA_EVENTSr(   r/   r   SCADA_EVENT_PREFr5  rZ   r  r	  r
  
rx   r   r   r  r   r   r9  rR   r  r<   s
     `    @@@r   notify_scadar?  [  s     L 

3U;	-.>u.EF
	!%1H	MGT$_D+ T288:  	r   rpm_notificationszrpm.part.awaitingzrpm.part.approvedzrpm.part.orderedzrpm.part.deliveredzrpm.part.stockedawaitingapprovedr   	deliveredstocked)zpart awaiting approvalzis awaiting approval)zpart approvedzhas been approved)zpart orderedzhas been ordered)zpart deliveredzhas been delivered)zpart stockedzhas been stockedzMobile MachinezPreventative Maintenance)mobilemachine
preventivec                    | sg S 	 t        j                  t        j                               }t        |t              sg S |j                  d      }t        |t              sg S |j                  |       }t        |t              rA|D cg c]5  }t        |      j                         st        |      j                         7 c}S g S # t         j                  t
        f$ r g cY S w xY wc c}w )zCompany-level policy-enforced RPM recipients for ``wor_type`` (DVI-614
    follow-up). A user on this list receives every RPM part event for the type,
    regardless of their per-user grid. Returns original-cased emails.policy_notify)rG   rq   RPM_APPROVAL_FILErr   rs   rp   rK   rt   r.   r   rL   rf   )rw   r   pnlstr   s        r   _rpm_policy_notify_emailsrL    s     	zz+5578 dD!		/	"Bb$	
&&
C>Hd>SC:q3q6<<>CFLLN:[Y[[   '* 	 ;s   'C C'&C'C$#C$c                   
 g t               c
t        j                  |      }| r|s
S 
fd}t        |       D ]
  } ||        	 t	        t
        j                  d            }|D ]  }	 t        j                  |j                               }t        |t              s8|j                  t              }t        |t              s^|j                  |       xs i }	t        |	t              r|	j                  |      s ||j                  d      xs |j                          
S # t        $ r 
cY S w xY w# t        j                  t        f$ r Y w xY w)a^  De-duplicated emails of users to notify for (wor_type x event):
    the per-user opt-in grid (``rpm_notifications[wor_type][col]`` across every
    USER_SETTINGS_DIR/*.json) OR-merged with the company-level policy-enforced
    allowlist for the type (which receives all events).

    Returns original-cased email strings, order-stable, no duplicates.c                     t        |       }|r6|vr1j                  |       j                  | j                                y y y r   r   )rl   r   r   r   s     r   r   z$resolve_rpm_recipients.<locals>._add  s;    &D$HHTNNN5;;=) %4r   rk   rl   )r   RPM_EVENT_COLr.   rL  rm   rn   ro   rp   rG   rq   rr   rs   rK   rt   RPM_NOTIFY_KEYrv   )rw   rx   colr   rl   ry   rz   r{   r|   r}   r   r   s             @@r   resolve_rpm_recipientsrR    s=    MGT


E
"C* +84 U(--h78  /	zz",,.1H (D)^,%&YYx(.B
:t,1DX\\'"-bgg./ N!  
 $$g. 		s$   
D( .#D9(D65D69EEc                    t         j                  |       }|sy|\  }}|j                  d      xs d}t        j                  ||xs d      }|j                  d      xs d}|j                  d      xs d}|j                  d      xs d}	|j                  d	      xs d}
d
j                  |      }|	r|dj                  |	      z  }dt	        |      fg}|r|j                  dt	        |      f       |j                  dt	        |      f       |	r|j                  dt	        |	      f       |
r!|
|	k7  r|j                  dt	        |
      f       dj                  d |D              }d|z   dz   }dj                  |t	        |            }dj                  ||      }d}t        t	        |j                               |d|      }||fS )zRender (subject, html) for an RPM part event. None for an unknown event.

    Context keys: ``wor_type``, ``wo_label`` (WO number/label), ``wo_title``
    (display title), ``part_name``, ``part_number`` (all strings, optional).Nrw   r   z
Work Order	part_namez(unnamed part)part_numberwo_labelwo_titlezRepair Parts: {}z ({})PartzPart #zWork Order typeTitlec              3   H   K   | ]  \  }}d j                  ||        ywr!  r   r"  s      r   r$  z#render_rpm_email.<locals>.<genexpr>  s3      ? .6S#	 3C(	)?s    "r   r   z2A repair part {verb} on a {type_label} work order.)verb
type_labelr%  r&  z<hr style="margin:8px 0 14px;border:none;border-top:1px solid #e5e7eb;"><p style="font-size:12px;color:#9ca3af;text-align:center;margin:0;line-height:1.6;">You are receiving this because you enabled Repair Parts notifications in your Togen settings.</p>zRepair Parts Managerr'  )	_RPM_EVENT_COPYr.   _RPM_TYPE_LABELr   r   r   r   r   
capitalize)rx   r   r   subject_phraser[  rw   r\  rT  rU  rV  rW  r<   r   r4  r   r   r   r   rR   s                      r   render_rpm_emailra    s   
 u%DND{{:&,"H $$Xx/G<HJK(<,<I++m,2K{{:&,"H{{:&,"H ''7G7>>(++T)_%&DXtK012KK"D$456\4>23H(Wd8n-.ww ? :>	? ?H	%'/	02<	=  AGGd:. H 0E 	Q	e<	0 	: 
 >,,./9'[BD D=r   c                >  	 | t         vrt        j                  d|        g S t        |j	                  d      |       sg S t        | |      }|sg S |\  	|xs t        	fd}|r't        j                  |d      j                          S  |        S )ux  Send a Repair Parts Manager part-event email to opted-in users.

    Recipients default to everyone whose ``rpm_notifications`` grid enables
    (``context['wor_type']`` x event). Like :func:`notify_scada`, idempotency is
    the caller's responsibility — callers fire only on a state transition.

    Returns the resolved recipient list (so callers/tests can assert on it).znotify_rpm: unknown event %rrw   c                  (    D ]  }  |         y r   r   r8  s    r   r9  znotify_rpm.<locals>._run=  r:  r   Tr;  )
RPM_PART_EVENTSr(   r/   rR  r.   ra  rZ   r  r	  r
  r>  s
     `    @@@r   
notify_rpmre  (  s     O#

159	+GKK
,CUK
	w/H	MGT$_D+ T288:  	r   r   )Z__doc__r]   rG   r   r  r   r   pathlibr   r*   requestsrM   EVENT_WO_CREATEDEVENT_WO_STATUS_IN_PROGRESSEVENT_WO_STATUS_COMPLETEDEVENT_WO_STATUS_CANCELLEDEVENT_WO_RECURRING_DUE_SOONEVENT_WO_RECURRING_OVERDUEr   r   r   r  	frozensetr   r(  r)  r<  r=  __file__resolveparent	_BASE_DIRrn   r   r   r   rI  ru   r   r.   r%   r&   
_AZ_TENANTr'   r   r-   Lockr   logging	getLoggerr(   r   r5   rX   rZ   rc   ri   r~   r   r   r   r   r   r   r   r   r   r   r   r   r   r   r  r   r5  r?  rP  EVENT_RPM_PART_AWAITINGEVENT_RPM_PART_APPROVEDEVENT_RPM_PART_ORDEREDEVENT_RPM_PART_DELIVEREDEVENT_RPM_PART_STOCKEDrd  rO  r]  r^  rL  rR  ra  re  r   r   r   <module>r}     s{  >   	  '   ! !4  >  <  <  >  =  A  9  8  
     / 7 !#;< |n  N""$++	/ !$;;  11  //  33  . 

0"5

4b9


0"5
EO4ZLAUW**..!2B778 INN	 w./ 6D!#bb*/Z$8v
/8A!H*H  ) !HG\F""     "         "  !      C%P	#N
9169@3$ *.d !PbJ 04T $d %. . - / -   jjiki PDBFB &,\&(V2j .2d4 r   