
    5j                     x   d Z ddl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
mZ ddlZddlZej                  j!                  dd      Zej                  j!                  dd      Zej                  j!                  d	d      Zd
e Zej                  j!                  dd      Z ej,                  d      Z ej,                  dej0                        Z ej,                  dej0                        Zd Zd Zd Zd Zd Zd Z 	 	 d>dZ!d Z"d?dZ# e	e$      jK                         jL                  dz  Z'dddddddddddddd gd!Z(dZ)d" Z*d@d#Z+d$ Z,d% Z-d& Z.d' Z/d( Z0d@d)Z1d@d*Z2d+Z3dAd,Z4d- Z5dAd.Z6ddddd/d0Z7d@d1Z8d2 Z9d@d3Z: G d4 d5      Z;i Z<d6Z=d7 Z>d@d8Z?d9 Z@ e> e;d6d:dd;e#e2e+e(e1e)d<=             y)Bu[  
scada_report.py — SCADA daily-log report engine for Togen (DVI-482 / DVI-486).

Self-contained extraction of the SCADA reporting engine that ST1 (DVI-482) first
authored inside app.py. It is split out here so both the Flask app and the
standalone scheduler (``scada_report_scheduler.py``, DVI-486) can build reports
from a single source of truth without importing app.py — app.py spins up
background loops at import time and is unsuitable for a one-shot cron/timer job
(mirrors how ``notification_scheduler.py`` imports ``notifications.py``, never
app.py).

The engine reads daily X-400 "Log" emails from the SCADA mailbox via Microsoft
Graph, parses their multi-row tables (columns derived per-email from the header
row), bins rows by their parsed Date Time, and renders an XLSX workbook with one
sheet per day. It is Flask-independent and configured purely from env vars, so it
can be imported and unit-tested in isolation.

Required env vars (same as app.py / notifications.py):
    AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_TENANT_ID
Optional env var:
    SCADA_MAILBOX  (default scadalogs@icastinc.com)
    N)datetime	timedelta)Path)quoteAZURE_CLIENT_ID AZURE_CLIENT_SECRETAZURE_TENANT_IDz"https://login.microsoftonline.com/SCADA_MAILBOXzscadalogs@icastinc.comz8^(\d{1,2})/(\d{1,2})/(\d{4})\s+(\d{1,2}):(\d{2}):(\d{2})z^\s*x-400\s*-\s*trigger\bz^\s*x-400\s*-\s*log\bc                 h    | xs d}t         j                  |      ryt        j                  |      ryy)zClassify a SCADA email by subject line: 'log', 'trigger', or None.

    Routing both parsers off the subject keeps daily-log tables out of the
    Trigger-format status parser and vice-versa.
    r   triggerlogN)_SCADA_SUBJECT_TRIGGER_REmatch_SCADA_SUBJECT_LOG_RE)subjectss     #/var/www/html/togen/scada_report.pyscada_subject_kindr   C   s2     	2A &&q)""1%    c                 <   | j                  di       xs i }|j                  dd      xs d}|j                  dd      j                         dk(  xs' t        j                  d|t        j                        du}|rt        j
                  dd	|t        j                  
      }t        j
                  dd|t        j                  
      }t        j
                  dd|t        j                  
      }t        j
                  dd|      }t        j                  |      }|S )u  Return a daily-log email body as tab-delimited plain text.

    Converts HTML table markup to tabs/newlines so the same tab-delimited parser
    handles both HTML and text emails. bodyPreview is intentionally ignored — it
    truncates the multi-row table.
    bodycontentr   contentTypehtmlz<(table|td|tr)\bNz</(td|th)\s*>	)flagsz</(tr|p|div)\s*>
z	<br\s*/?>z<[^>]+>)getlowerresearchIsubr   unescape)msgbody_objr   is_htmls       r   _scada_log_body_textr)   Q   s     wwvr"(bHll9b)/RG||M2.446&@ L))/"$$?tK &&)4E&&,dG244H&&tWBDDA&&R1--(Nr   c                     d| v rdS dS )u  Pick the cell delimiter for a log body: tab (HTML-converted) or comma.

    Real X-400 plain-text log emails are CSV ("Date Time,Vin(V),K1 Temp(F),...")
    — the original tab-only split left every line as one cell, which is what
    collapsed the XLSX report into a single column (DVI-1095). Tab wins when
    present so HTML-table bodies (converted to tabs by _scada_log_body_text)
    keep working even though their values never contain tabs.
    r   , )	body_texts    r   _scada_log_delimiterr.   e   s     9$4-#-r   c           	         t        |       }| j                         }g }|D ][  }|j                  |      D cg c]  }|j                          }}|s2|d   j	                         dk(  sI|D cg c]  }|s|	 }} n g }|D ]  }|j                  |      }t
        j                  |d   j                               }|s>|D cg c]  }|j                          }	}t        |      }
t        |
      t        |	      k  r8|
j                  dt        |
      dz           t        |
      t        |	      k  r8t        t        |	            D ci c]  }|
|   |	|    }}	 d |j                         D        \  }}}}}}t        ||||||      |d<   |j                  |       " |s(|r&|d   j                         D cg c]
  }|dk7  s	| }}||dS c c}w c c}w c c}w c c}w # t        t        f$ r d|d<   Y mw xY wc c}w )	aJ  Parse a daily X-400 log email body into a delimited table (tab or CSV).

    Returns {"columns": [...], "rows": [...]}. Columns come verbatim from the
    email's header row ("Date Time | <sensors...> | Trigger"). Each row is a dict
    {col: value, ..., "_dt": datetime|None}. Missing readings ("x.x") are kept
    verbatim.
    r   z	date timeColumn   c              3   2   K   | ]  }t        |        y wNint.0gs     r   	<genexpr>z)_parse_scada_log_email.<locals>.<genexpr>   s     %Ac!f%A   _dtN)columnsrows)r.   
splitlinessplitstripr    _SCADA_LOG_DT_REr   listlenappendrangegroupsr   
ValueError	TypeErrorkeys)r-   delimlinesr<   lineccellsr=   mvaluescolskrowmodayrhhmmsss                      r   _parse_scada_log_emailrZ   q   s    !+E  "EG $(JJu$56q66U1X^^%4"'-Q1q-G-	 D 

5!""58>>#34%*+!'')++G}$i#f+%KK&TQ01 $i#f+%+0V+=>atAwq	!>>	%Aahhj%A"BBB!"b"b"b9CJ 	C" t"1glln;U
1;;--3 7- , ? I& 	CJ	
 <s;   G-G5GGG# 1G(>
H	H(G?>G?c                 L    t        j                  dd|       j                         S )zDStrip the unit suffix from a column name: 'K1 Temp(F)' -> 'K1 Temp'.z\s*\([^)]*\)\s*$r   )r!   r$   r@   )names    r   _scada_norm_sensorr]      s    66%r406688r   c                     	 | j                         j                  d      xs i j                  dd      }|xs | j                  dd S # t        $ r d}Y  w xY w)u   Short human-readable message from a Graph error response body.

    Surfaced so callers can distinguish e.g. a tenant ApplicationAccessPolicy
    (RAOP) block from a missing Mail.Read grant (DVI-1095 P0 — the two need
    different admins to fix).
    errormessager   Ni,  )jsonr   	Exceptiontext)respr&   s     r   _graph_error_messagere      sZ    yy{w'-2229bA !$))DS/!  s   3A AAc           	         	 t        j                  t        t        t              }|j                  dg      }|j                  d      }|sg |j                  dd      fS g }| r|j                  d|         |r|j                  d|        d	t         d
t        |       d| d}	|r"|	dt        dj                  |      d      z   z  }	d| dd}
g }|	rt        |      |k  rt        j                  |	|
d      }|j                  dk(  rg dt        |      z   fS |j                  dk7  rg d|j                   dt        |       fS |j!                         }|j#                  |j                  dg              |j                  d      }	|	rt        |      |k  r|D cg c]"  }t%        |j                  dd            dk(  r|$ }}|d fS c c}w # t&        $ r}g t)        |      fcY d }~S d }~ww xY w)!a2  Fetch daily-log mailbox messages (subject contains 'Log') with full body.

    Filters receivedDateTime server-side when a window is given; the subject
    match is client-side. Returns (messages, error); error is None on success,
    "403:<detail>" when mailbox access is denied, else a short string.
    )	authorityclient_credentialz$https://graph.microsoft.com/.default)scopesaccess_tokenerror_descriptionzno access_tokenzreceivedDateTime ge zreceivedDateTime le z'https://graph.microsoft.com/v1.0/users/z/messages?$top=z&$orderby=receivedDateTime z&&$select=subject,receivedDateTime,bodyz	&$filter=z and  )safezBearer zapplication/json)AuthorizationzContent-Type<   )headerstimeouti  z403:   z
Graph API z: valuez@odata.nextLinkr   r   r   N)msalConfidentialClientApplication_AZ_CLIENT_ID_AZ_AUTHORITY_HOME
_AZ_SECRETacquire_token_for_clientr   rD   r   r5   r   joinrC   _http_requestsstatus_codere   ra   extendr   rb   str)received_gereceived_le	page_sizeordermax_messages
app_clienttoken_resulttokenfiltersurlrp   msgsrd   datarO   log_msgsexcs                    r   _scada_fetch_log_messagesr      s%   &77%7:W
!:::; ; =  0|''(;=NOOONN1+?@NN1+?@8 HI''B5'78 ;w||G'<3!GGGC&-eW$5GYZc$i,.!%%c7BGD3& 6$8$>>>>3&Z(8(8'9<PQU<V;WXXX99;DKK"-.((,-C c$i,.  $ J!)!%%	2*>?5H  J J~J  3s8|sJ   AG  B;G +G AG G 'GG G 	G1G,&G1,G1c                     t        ddd      \  } }|s| syg }| D ]5  }t        t        |            }|j                  d |d   D               |s5 n |r't	        |      j                         j                         S | d   j                  dd	      }	 t        j                  |j                  d
d            j                         j                         S # t        t        f$ r Y yw xY w)zEarliest log day available in the mailbox (capped by retention), or None.

    Surfaced so the report filter UI (ST2) can bound its date picker.
    ascd   )r   r   r   Nc              3   J   K   | ]  }|j                  d       s|d      yw)r;   N)r   )r7   rs     r   r9   z+_scada_earliest_log_date.<locals>.<genexpr>   s     DquuU|1U8Ds   #
#r=   r   receivedDateTimer   Zz+00:00)r   rZ   r)   r}   mindate	isoformatr   r   fromisoformatreplacerG   AttributeError)r   errdtsr&   parsedreceiveds         r   _scada_earliest_log_dater      s    
 *SVWID#
$
C '(<S(AB

DVF^DD	
 3x}}((**Aw{{-r2H%%h&6&6sH&EFKKMWWYY' s   A C C%$C%c                    dx}}| r:t        | j                  | j                  | j                        j	                  d      }|rIt        |j                  |j                  |j                        t        d      z   }|j	                  d      }t        ||      \  }}|r	g g g dd|dS g }i }	|D ]  }
t        t        |
            }|d	   r|s|d	   }|d
   D ]W  }|j                  d      }||j                         }| r|| k  r/|r||kD  r7|	j                  |g       j                  |       Y  |sJ|	rHt        t        |	j                                     d   }|j!                         D cg c]
  }|dk7  s	| }}t#        |      dk\  r|dd ng }|D cg c]  }t%        |       }}|rO|D ch c]#  }|j'                         s|j'                         % }}|D cg c]  }||v st%        |      |v r| }}n|}t#        |      dk\  r|dd |z   |dd z   n
t)        |      }g }d}t+        |	      D ]s  }t+        |	|   d       }|D cg c]"  }|D cg c]  }|j                  |d       c}$ }}}|t#        |      z  }|j                  |j-                         ||d       u |||t/               |ddS c c}w c c}w c c}w c c}w c c}w c c}}w )a  Aggregate daily-log rows for [date_from, date_to] (inclusive) into per-day
    tables.

    date_from/date_to are date objects or None. Log emails arrive the morning
    after the log day, so the received window is widened by +2 days and rows are
    binned by their parsed Date Time. ``sensors`` (normalized or native names)
    filters which sensor columns appear; Date Time + Trigger are always kept.
    Returns {"days", "columns", "available_sensors", "earliest_date",
    "row_count", "error"}.
    Nz%Y-%m-%dT00:00:00Z   )daysz%Y-%m-%dT23:59:59Z)r   r   r   )r   r<   available_sensorsearliest_date	row_countr_   r<   r=   r;   r1   c                     | d   S )Nr;   r,   )r   s    r   <lambda>z%_build_scada_report.<locals>.<lambda>3  s
    QuX r   keyr   r   r<   r=   )r   yearmonthdaystrftimer   r   rZ   r)   r   r   
setdefaultrD   nextiterrP   rI   rC   r]   r@   rB   sortedr   r   )	date_fromdate_tosensorsr   r   widenedr   r   native_columnsby_dayr&   r   rS   dtr   samplerR   middlerM   	availabler   wantedkeep_middleout_columnsr   r   day_rowsr   coltables                                 r   _build_scada_reportr      s    !%$K+y~~y	 !!)*>!? 	7<<DQ &&';<)k{[ID#
r!%AE 	E NF 3'(<S(AB)^#I.N&> 		3CBz'')CS9_3=c2&--c2		3	3 fd6==?+,Q/%+[[]Aa5j!AA &)%8A%=^Ab!2F0671#A&7I7%,:	!'')::"( JQv+);A)>&)H  J J ~!# ""1%3nRS6II)-n)=  DIf~ V&++=>BJKQK8S!%%R.8KKSZ	S]]_eTU	V &13 / B
 8:J 9Ks<   ,
J:7J:J?0KKK		KK.KKzscada_config.jsong     F@g      d@ro   T)lowest_overalllowest_sustainedhighest_sustainedhighest_overallroc1id	max_deltawindow_minutesenablednotify)	low_limit
high_limitsustain_minutesr   
rate_rulesc                 h   g }t               }t        |       D ]   \  }}t        |t              s	 t	        |j                  d            }t        |j                  d            }|dk  s|dk  rXt        |j                  d      xs d      j                         xs d|dz    }|d}	}||v r| d	|	 }|	dz  }	||v r|j                  |       |j                  |||t        |j                  d
d            t        |j                  dd            d        |S # t        t        f$ r Y w xY w)a:  Validate/normalize a raw ``rate_rules`` list, dropping malformed entries.

    Each kept rule has a unique string ``id``, positive ``max_delta`` (F) and
    ``window_minutes`` (int), plus bool ``enabled``/``notify``. Bad rules are
    dropped rather than raising so a hand-edited config still builds a report.
    r   r   r   r   r   rocr1   r   _r   Tr   Fr   )set	enumerate
isinstancedictfloatr   r5   rH   rG   r~   r@   addrD   bool)
	raw_rulesoutseenir   r   windowridbasens
             r   _normalize_rate_rulesr   f  s@    C5D)$ 1!T"	aeeK01I/01F >Vq[!%%+#$**,=#a!eWqaTkF!A3-CFA Tk 	

"$AEE)T23155512
 	!. J# :& 		s   4DD10D1c                 *   | bi } t         j                         rL	 t        j                  t         j	                               }t        |j                  d      t              r|d   } dt        t        d         i}dt        fdt        fdt        ffD ](  \  }}	  || j                  |t        |               ||<   * | j                  d      }t        |t              r$t        D ]  }||v st!        ||         |d   |<    d| v r&t        | d   t"              rt%        | d         |d<   |S t        d   D cg c]  }t        |       c}|d<   |S # t        j                  t        f$ r Y w xY w# t        t        f$ r t        |   ||<   Y w xY wc c}w )u   Return merged criteria settings: ``raw`` (or scada_config.json's
    ``report_criteria``) over SCADA_CRITERIA_DEFAULTS. Bad values fall back to
    the default rather than raising — the report must still build when the
    config file is hand-edited.report_criteriar   r   r   r   r   )SCADA_CONFIG_PATHis_filera   loads	read_textr   r   r   JSONDecodeErrorOSErrorSCADA_CRITERIA_DEFAULTSr   r5   rH   rG   _CRITERIA_KEYSr   rB   r   )rawsavedr   r   castr   rR   r   s           r   scada_report_criteriar     s   
 {$$&

#4#>#>#@Aeii(9:DA 12C d29=>
?C"E*\5,A(#.0 4	T	4CGGC)@)EFGCH4 ggi G'4  	5AG|$($4Iq!	5
 sz#l*;TB1#l2CDL J /Fl.STT!WTLJ) (('2  :& 	4.s3CH	4 Us*   AE "E/5FE,+E,/FFc           
         i }| d   D ]  }|d   }t        |      D cg c](  \  }}|dt        |      dz
  fvrd|v r|t        |      f* }}}|d   D ]  }t        j	                  |d   xs dj                               }|s0	 d |j                         D        \  }	}
}}}}t        ||	|
|||      }|D ]7  \  }}	 t        ||         }|j                  |g       j                  ||f       9   |j                         D ]  }|j                  d	 
        |S c c}}w # t        t        f$ r Y w xY w# t        t        t        f$ r Y w xY w)u  Extract per-Temp-sensor time series from a built report.

    Returns {sensor: [(datetime, float), ...]} sorted by time. Non-numeric
    values ("x.x" gaps) are skipped; RH / Vin / Trigger columns are ignored —
    the criteria are temperature-compliance checks.
    r   r<   r   r1   Tempr=   r   c              3   2   K   | ]  }t        |        y wr3   r4   r6   s     r   r9   z'_scada_sensor_series.<locals>.<genexpr>  s     )EQ#a&)Er:   c                     | d   S )Nr   r,   )ps    r   r   z&_scada_sensor_series.<locals>.<lambda>  s
    qt r   r   )r   rC   r]   rA   r   r@   rF   r   rG   rH   r   
IndexErrorr   rD   rP   sort)reportseriesr   rQ   r   rM   temp_idxrS   rO   rT   rU   rV   rW   rX   rY   r   sensorvalptss                      r   _scada_sensor_seriesr    s    Ff~ @9~;DT? D41aCIM 22v{ *1-. D Dv; 	@C &&A"';';'=>A)E!((*)E&BBBb"b"b"5 & @	6A-C !!&"-44b#Y?@	@	@& }} %^$%M'D 	* 
 ":z: s)   -D.D";D7"D43D47E	E	c                 R   d}g }| D ]  \  }} ||      r|r!||d   d   z
  j                         dz  |kD  rg }|j                  ||f       |d   d   |d   d   z
  j                         dz  }|	||d   kD  sqt        |d       |d |D              |d   d   |d   d   d}g } |S )	u  Longest consecutive stretch of ``points`` where predicate(value) holds.

    Returns {"minutes", "extreme", "start", "end"} for the longest run or None;
    ``extreme_fn`` (min/max) picks the run's reported extreme. A gap between
    samples longer than ``gap_break_minutes`` breaks the run — missing data must
    not silently count as a sustained excursion. Duration is last-sample minus
    first-sample, so a single out-of-range sample is 0 min (the 15-min sample
    interval bounds what "sustained" can resolve).
    Nr   r   ro   minutesr1   c              3   &   K   | ]	  \  }}|  y wr3   r,   )r7   r   vs      r   r9   z_longest_run.<locals>.<genexpr>  s     -@DAqa-@s   )r  extremestartend)total_secondsrD   round)	points	predicate
extreme_fngap_break_minutesbestrunr   r   r  s	            r   _longest_runr    s     D
C CS>SWQZ6682=@QQJJCy!2wqzCF1I-<<>CG|wi8#(!#4#--@C-@#@!$Q3r71:? C Kr   c                    d}d}t        t        |             D ]  }| |   \  }}||k  rI|| |   d   z
  j                         dz  |kD  r*|dz  }||k  r || |   d   z
  j                         dz  |kD  r*t        ||      D ]T  }| |   \  }}	t        ||	z
        }
|	|
|d   kD  s$t	        |
d      |||	|t	        ||z
  j                         dz  d      d}V  |S )ur  Largest absolute value change between any two readings that fall within a
    rolling ``window_minutes`` window (DVI-1148, change over time).

    Returns {"delta", "start", "end", "start_val", "end_val", "span_minutes"}
    for the worst change, or None when fewer than two readings share a window.
    Tracks absolute change (rise OR fall — both are thermal-integrity risks).
    Gap-aware: only pairs whose timestamps are within ``window_minutes`` of each
    other are compared, so a data gap wider than the window is never mistaken
    for an instantaneous jump. A trailing left pointer keeps this ~O(n · window).
    Nr   ro   r1   deltar   )r  r  r	  	start_valend_valspan_minutes)rE   rC   r
  absr  )r  r   r  lohidt_hival_hirR   dt_kval_kr  s              r   _max_rate_of_changer    s    D	
BCK  r
v2g56":a=0??ABFW!GB 2g56":a=0??ABFWr2 		A )KD%'E|utG}4"5!_!%!&6$)54<*F*F*H2*Mq$Q					 Kr   c                 d    t        | t        |d               }|y|d   t        |d         kD  |fS )uy  Evaluate one rate rule against a (datetime, value) series (DVI-1148).

    Shared by the report Summary and the live notification path. Returns
    ``(violated, info)`` where ``info`` is the :func:`_max_rate_of_change`
    result (or None when the window holds fewer than two readings, in which case
    ``violated`` is False — an unmeasurable window is never an alert).
    r   )FNr  r   )r  r5   r   )r  ruler   s      r   scada_rate_rule_checkr"    sA     fc$/?*@&A
BC
{w<%[ 122C77r   c                 ,    | r| j                  d      S dS )Nz%m/%d/%Y %H:%Mr   )r   )r   s    r   _fmt_dtr$    s    ,.2;;'(6B6r   c                    t        t        |t              r|nd      }|d   |d   c|d   }|d   }t        |       }g }d}t	        |      D ]Y  }||   }	|	D 
cg c]  \  }
}|	 }}
}g }|j                  d      r[t        |      t        fd|	D              }k\  }|j                  dd	d
dt        |      |dd
dt        |       d       |j                  d      rt        |	fdt        t        |d            }|du xs |d   |k  }|j                  ddd
d| d|r|d   nd|r't        |d          dt        |d          d|d   d
dnd||rdd
d|d   d
d|d   d
d nd!d
dd       |j                  d"      rt        |	fd#t        t        |d            }|du xs |d   |k  }|j                  d"d$d
d| d|r|d   nd|r't        |d          dt        |d          d|d   d
dnd||rd%d
d|d   d
d&|d   d
d nd'd
dd       |j                  d(      r[t        |      t        fd)|	D              }k  }|j                  d(d*d
dt        |      |d+d
dt        |       d       |j                  d,g       D ]  }|j                  d      s|d-   |d.   |d/   }}}t        |	|      }|)|j                  d0| d1|d
d2| d|dddd3| d4d       [|d5   |k  }|j                  d0| d1|d
d2| d||d5   t        |d          dt        |d          d|d6   d
d|d7|d5   d
d8|d9   d
d:|d;   d
d<|d6   d
d	d        t        d= |D              }|xr |}|j                  |||d>       \ |||r|d?S dd?S c c}}
w )@a  Evaluate the enabled report criteria against a built report.

    Returns {"settings", "sensors": [{sensor, checks: [...], pass}], "pass"}.
    Each check: {key, label, threshold, observed, when, pass, detail}.
    ``pass`` is None (informational) when a check has no data to judge.
    Nr   r   r   r   Tr   c              3   4   K   | ]  \  }}|k(  s|  y wr3   r,   )r7   r   r  vmins      r   r9   z*_scada_criteria_summary.<locals>.<genexpr>-       9ur1qDy9   z(Lowest temp (entire period) at or above r8   FzLowest reading zF at )r   label	thresholdobservedwhenpassdetailr   c                     | k  S r3   r,   )r  lows    r   r   z)_scada_criteria_summary.<locals>.<lambda>7  s    a#g r   ro   r  zNever below zF for more than  minr  r  u    – r	  z (z min)r   zLongest stretch below z	F lasted z
 min (low zF)zNo readings below r   c                     | kD  S r3   r,   )r  highs    r   r   z)_scada_criteria_summary.<locals>.<lambda>F  s    a$h r   zNever above zLongest stretch above z min (high zNo readings above r   c              3   4   K   | ]  \  }}|k(  s|  y wr3   r,   )r7   r   r  vmaxs      r   r9   z*_scada_criteria_summary.<locals>.<genexpr>V  r(  r)  z)Highest temp (entire period) at or below zHighest reading r   r   r   r   rate_of_change_zChange no more than zF within any zNot enough data within a z) min window to measure the rate of changer  r  zLargest change zF (r  zF to r  zF) over c              3   *   K   | ]  }|d    du  yw)r/  FNr,   )r7   rM   s     r   r9   z*_scada_criteria_summary.<locals>.<genexpr>|  s     AQ!F)50As   )r   checksr/  )settingsr   r/  )r   r   r   r  r   r   r   r   rD   r$  r  maxr  all)r   criteriacritsustainr   r   sensors_outoverall_passr   r  r   r  valsr:  r.  okr  r!  mdwmr   r   sensor_passr5  r2  r7  r'  s                          @@@@r   _scada_criteria_summaryrH    s1    !Z$-GTRD[!4#5IC$%G9oG!&)FKL. XVVn!"da"";;'(t9D999DBMM'CC7!L dGDM+D85H  ;;)*s$5sC<LMC9I' 9BMM)'Aw.>witL .1C	Nt:= $CL12%E
8K7Lc)nQ/u6CE " 4C7)!)nQ/z#i.9K2O);C7!'D  ;;*+s$6S"=MNC9I' 9BMM*'Qx/?yM!.1C	Nt:= $CL12%E
8K7Lc)nQ/u6CE " 4D89!)nQ/{3y>!:LBP);D81'E  ;;()t9D999DBMM(DT!HAN!tWT],T!HE'$-I  HH\2. 	D88I&{+T2B-CT$ZCB%c2.C{,SE23Bq6rd$O!#r !:2$ ?<  <  W#BMM(./1v]2$dKS\#CL12%E
8K7L M 035:,S\!,< =";/2%Iq7I!.1!4D:
 
!	8 A&AA#3fTUqXVt $/L; ;59; ;q #s   N:c                    ddl m} ddlm}  |       }|j	                  |j
                         |r|d   r|j                  d      } |d      }|d	   }|j                  d
g        |dd      |d   _        | d   }|r|d   d    d|d   d    nd}	|j                  d|	g       |j                  dd|d   dd|d   dd|d    dg       |d   }
|j                  d|
rdn|
d u rd!nd"g       |j                  d#d$%      } |d|
rd&nd'(      |_        |j                  g        g d)}|j                  |       t        d*t        |      d*z         D ]  }||j                  d+|%      _         |d   D ]~  }|d,   D ]t  }|d   rdn
|d   d u rd!nd-}|j                  |d.   |d/   |d0   |d1   ||d2   g       |j                  |j                  d3%      } ||d   d u |d   d u rd'nd&(      |_        v  d4d5d6d7d8d9d:}|j                         D ]  \  }}||j                  |   _         | d   s|j                  d;       | d   D ]  }t!        j"                  d<d=|d         d>d? }|j                  |      }|j                  d@|d   g       |j                  g        |j                  |dA          |dB   D ]  }|j                  |         t%        j&                         }|j)                  |       |j+                  d       |S )CzBuild an XLSX workbook from a report dict: an optional criteria Summary
    sheet first, then one sheet per day (title row, blank row, column header
    row, data rows).r   )Workbook)Fontr   Summary)titleT)boldr;  u,   Kiln Temperature Report — Criteria Summary   )rN  sizeA1r   r   z to r   zno dataPeriodLimitszLow r   r8   z	F / High r   zF / Sustained window r   r3  r/  zOverall resultPASSFFAILNO DATA   r   )rS   columnFF008000FFCC0000)rN  color)Sensor	CriterionObservedWhenResultDetailr1      r:  zN/Ar   r+  r-  r.  r0        ,   
   $      8   )ABCDEr*  zNo Dataz[:\\/?*\[\]]-N   zX-400 - Logr<   r=   )openpyxlrJ  openpyxl.stylesrK  removeactivecreate_sheetrD   fontcellrE   rC   max_rowitemscolumn_dimensionswidthr!   r$   ioBytesIOsaveseek)r   criteria_summaryrJ  rK  wbwsrN  r   r   r   overallrw  headerrM   r   chkresultrcwidthsr   wr   rM  rS   bufs                            r   _scada_report_workbookr    so    "$	BIIbii,Y7__9_-Z(
		ABC$R04f~?CDGFO$Db&)9(:;
		8V$%
		8!K.+9Q|_Q4G H''():';&<DBC 	D #6*
		#$6Gu4D&)U 	Vww1Qw'd-4z*F	
		"P
		&q#f+/* 	1A,0BGG!G$)	1&y1 	YFh' Y$'K&$'K5$8&e 		6(+S\z?CKXP QWWAW6CK5$836v;%3GZZYY	Y "12Flln 	0FC./B  %+	0 &>
i(f~ S[9#2>__5_)
		=#f+./
		"
		#i.!v; 	CIIcN	 **,CGGCLHHQKJr   r1   c                    | j                  dg       D cg c]W  }|j                  d      t        |j                  dg             |j                  dg       D cg c]  }t        |       c}dY }}}d}|rD|j                  d      r3|j                  d      |j                  d	      |j                  d      d
}t        | j                  dg             | j                  dd      t        | j                  dg             | j                  d      t        |      |r|d   d   nd|r|d   d   ndd}|r|j                  |       t        |||dS c c}w c c}}w )u$  Normalize a built report + criteria summary into the viewer view-model.

    Returns ``{version, meta, summary, days:[{date, columns, rows}]}``. ``summary``
    is the criteria pass/fail block (``{pass, settings, sensors}``) or None when
    the report type declares no criteria. ``meta`` merges derived facts (period,
    row count, sensors) with any caller-supplied metadata (schedule name, source,
    generated-at). All values are JSON-serializable — the criteria summary uses
    pre-formatted ``when`` strings, so no datetime leaks in.
    r   r   r<   r=   r   Nr   r/  r;  )r/  r;  r   r   r   r   r   r   )r<   r   r   r   	day_countr   r   )versionmetasummaryr   )r   rB   rC   updateVIEW_MODEL_VERSION)r   r  r  dr   r   r  vm_metas           r   build_report_view_modelr    sU    ZZ+-  UU6]QUU9b12'(uuVR'89!d1g9; -D - G,00;$((0(,,Z8'++I6
 

9b12ZZQ/!&**-@""EFO4Y(,T!WV_$'+48F#G t%	 ) :-s   AEE+EEc                 6    t        |       j                  d      S )zASibling ``<report>.json`` path for a generated ``<report>.xlsx``.z.json)r   with_suffix)	xlsx_paths    r   view_model_path_forr    s    	?&&w//r   c                     t        |       }t        |||      }|j                  t        j                  |d             |S )zPersist the sibling ``<report>.json`` view-model beside a generated XLSX.

    Returns the JSON path. Raises OSError on write failure so the caller can
    decide whether that should degrade the (already-persisted) report/email.
    r   )indent)r  r  
write_textra   dumps)r  r   r  r  	json_pathvms         r   write_report_view_modelr    s<     $I.I	 )94	@BBq12r   
exceptions)include_viewer_linkinclude_summaryinclude_out_of_specinclude_rate_violationssummary_modec                    | bi } t         j                         rL	 t        j                  t         j	                               }t        |j                  d      t              r|d   } t        t              }t        | t              r7dD ]  }|| v st        | |         ||<    | j                  d      }|dv r||d<   |S # t        j                  t        f$ r Y sw xY w)u  Merged report-email settings: ``raw`` (or scada_config.json's
    ``report_email``) over REPORT_EMAIL_DEFAULTS.

    Bad/absent values fall back to the default rather than raising — the email
    must still send when the config is missing or hand-edited.
    report_email)r  r  r  r  r  )r  full)r   r   ra   r   r   r   r   r   r   r   REPORT_EMAIL_DEFAULTSr   )r   r   r   r   modes        r   report_email_settingsr    s     {$$&

#4#>#>#@Aeii7>/C $
%C#tF 	*CczC>C	* ww~&))"&CJ (('2 s   AB= =CCc                 x    t        |       j                  dd      j                  dd      j                  dd      S )N&z&amp;<z&lt;>z&gt;)r~   r   )rs   s    r   
_email_escr  8  s1    JWS'"773#7V8LNr   c                 B   |xs
 t               }|j                  d      sy|r|j                  d      sy|j                  dd      dk7  }|j                  dd      }|j                  d	d      }g }|d   D ]  }|j                  d
g       D ]  }t        |j                  dd            j                  d      }	|	r|s2|	s|s7|j                  d      du }
|r|
sO|j	                  |j                  dd      |	|j                  d|j                  dd            |
f         |j                  d      }|du rd\  }}}n|du rd\  }}}nd\  }}}dj                  |      dj                  |||      g}|rwg }|D ]I  \  }}	}}
|	rdnd}|
rdnd}|j	                  dj                  t        |      ||t        |                   K |j	                  ddj                  |      z   d z          n|j	                  d!       |j	                  d"       dj                  |      S )#u  Compact HTML "Attention" block for a report email body (DVI-1165).

    Renders the overall PASS/FAIL result plus, per the toggles in ``opts``, the
    out-of-spec sensor checks (over-high / under-low overall+sustained) and the
    change-over-time (rate-rule) violations. ``summary_mode`` "exceptions"
    (default) shows only *failing* checks; "full" shows every enabled check.

    Returns "" when there is nothing to render — the summary is disabled, the
    report type has no criteria, or (exceptions-only) every criterion passed and
    there are no rows to show. Callers append the returned fragment into the
    email body; an empty string simply adds nothing.
    r  r   r   r  r  r  r  Tr  r:  r   r8  r/  Fr   r0  r+  )rU  #b91c1cz#fef2f2)rT  #166534z#f0fdf4)rV  z#4b5563z#f9fafbzW<div style="margin:16px 0 4px;border:1px solid {c};border-radius:8px;overflow:hidden;">)rM   z<div style="background:{bg};padding:9px 14px;"><span style="font-size:13px;font-weight:700;color:{c};text-transform:uppercase;letter-spacing:.04em;">Attention &middot; {st}</span></div>)bgrM   stzRate of changezOut of specr  r  ac  <tr style="border-top:1px solid #f3f4f6;"><td style="padding:6px 12px 6px 14px;font-size:13px;color:#111827;white-space:nowrap;vertical-align:top;">{s}</td><td style="padding:6px 12px;font-size:12px;font-weight:600;color:{dot};white-space:nowrap;vertical-align:top;">{tag}</td><td style="padding:6px 14px 6px 0;font-size:13px;color:#374151;">{d}</td></tr>)r   dottagr  zj<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="border-collapse:collapse;">z</table>zk<div style="padding:9px 14px;font-size:13px;color:#374151;">All monitored criteria are within limits.</div>z</div>)r  r   r~   
startswithrD   formatr  rz   )r   r  optsexceptions_only	show_spec	show_rater=   r   r  is_ratefailedr  
status_txtr[  r  partstrsr0  r  r  s                       r   build_report_email_summaryr  =  sk    *(*D88%&#3#7#7	#Bhh~|<FO.5I2D9ID"9- K::h+ 
	KC#''%,-889JKGy9WWV_-FvKKHb173777B+?@&J K
	KK ""6*G% <
E2	D <
E2 ?
E2	#VeV_	/ 06vu:D 06 0FE /3 	++FGVV&-"=C%)9CJJ0 17 (cs ( 17 1*	+	+ 	>ggcl'(	)
 	>	? 
LL775>r   c                   ^    e Zd ZdZdddddddddZed        Zdd	Zdd
ZddZ	ddZ
d Zy)
ReportTypea!  Descriptor bundling a report's data source, criteria, and XLSX layout.

    ``build(date_from, date_to, sensors) -> report dict`` and
    ``workbook(report, criteria_summary) -> BytesIO`` are required. Criteria are
    optional: a type with ``has_criteria`` supplies ``criteria`` (a validator
    ``raw -> merged dict``) and ``criteria_summary`` (``(report, raw) -> summary``);
    ``criteria_config_key`` names where its settings live in scada_config.json
    and ``criteria_keys`` lists its per-check enable-flag keys (for the editor UI).
    r   r   Nr,   )r   descriptionr>  criteria_defaultsr  criteria_keyscriteria_config_keyc       	             || _         || _        || _        || _        || _        || _        || _        |	| _        |xs i | _        t        |
      | _
        || _        y r3   )r   r\   r   r  _build	_workbook	_criteria_criteria_summaryr  tupler  r  )selftype_idr\   buildworkbookr   r  r>  r  r  r  r  s               r   __init__zReportType.__init__  s`     	
&!!!1!2!8b"=1#6 r   c                 >    | j                   d uxr | j                  d uS r3   )r  r  r  s    r   has_criteriazReportType.has_criteria  s!    ~~T)Pd.D.DD.PPr   c                 (    | j                  |||      S r3   )r  )r  r   r   r   s       r   r  zReportType.build  s    {{9gw77r   c                 @    | j                   r| j                  |      S dS )zAMerged criteria settings for this type, or None when it has none.N)r  )r  r   s     r   r>  zReportType.criteria  s    &*nnt~~c">$>r   c                 B    | j                   r| j                  ||      S dS )zEvaluated pass/fail summary for this type, or None when it has none.

        ``raw`` is the persisted criteria block (or None to read the shared
        config file); the summary function re-merges it through the validator.
        N)r  r  )r  r   r   s      r   r  zReportType.criteria_summary  s$     7;6G6Gt%%fc2QTQr   c                 &    | j                  ||      S r3   )r  )r  r   r  s      r   r  zReportType.workbook  s    ~~f&677r   c                     | j                   | j                  | j                  | j                  | j                  t        | j                        | j                  dS )z;JSON-serializable descriptor for the report-type picker UI.)r   r\   r   r  r  r  r  )r   r\   r   r  r  rB   r  r  r  s    r   infozReportType.info  sL     ''IIZZ++ --!$"4"45#'#;#;
 	
r   NNNr3   )__name__
__module____qualname____doc__r  propertyr  r  r>  r  r  r  r,   r   r   r  r    sR     AB$$"&b%)7  Q Q8?R8

r   r  	kiln_tempc                 ,    | t         | j                  <   | S r3   )_REPORT_TYPESr   )report_types    r   register_report_typer    s    $/M+..!r   c                 B    | r| t         v r	t         |    S t         t           S )u  Resolve a report type by id, falling back to the default (kiln_temp).

    An unknown or empty id resolves to the default, so schedules created before
    the registry (which carry no ``report_type``) stay on the Kiln Temp engine —
    the source of the byte-identical guarantee.
    )r  DEFAULT_REPORT_TYPE_ID)r  s    r   get_report_typer    s%     7m+W%%/00r   c                  B    t        t        j                         d       S )z=All registered types, ordered for display (order, then name).c                 2    | j                   | j                  fS r3   )r   r\   )ts    r   r   z#list_report_types.<locals>.<lambda>  s    !&&8I r   r   )r   r  rP   r,   r   r   list_report_typesr    s    -&&(.IJJr   zKiln Temp ReportzLDaily X-400 kiln temperature logs with low/high/sustained compliance checks.r   )	r   r  r  r  r>  r  r  r  r  )NNi  desci  r  r3   )NN)Ar  r   r|  ra   osr!   r   r   pathlibr   urllib.parser   rt   requestsr{   environr   rv   rx   
_AZ_TENANTrw   r   compilerA   r#   r   r   r   r)   r.   rZ   r]   re   r   r   r   __file__resolveparentr   r   r   r   r   r  r  r  r"  r$  rH  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r,   r   r   <module>r     sC  .  	  	 	 (    !
 

0"5ZZ^^126
ZZ^^-r2
9*F 

0HI 2::YZ  'BJJ'CRTTJ "

#;RTTB (	.%.P9
" =AHL.b.Kl N**,336II   !	 DBD	*! ,: F F@6<87j;Z;P  %P0
	0  #  8N
N@;
 ;
| $ 
	1K
 Z#
&
#"-, ) r   