oscura 0.0.1__py3-none-any.whl → 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- oscura/__init__.py +813 -8
- oscura/__main__.py +392 -0
- oscura/analyzers/__init__.py +37 -0
- oscura/analyzers/digital/__init__.py +177 -0
- oscura/analyzers/digital/bus.py +691 -0
- oscura/analyzers/digital/clock.py +805 -0
- oscura/analyzers/digital/correlation.py +720 -0
- oscura/analyzers/digital/edges.py +632 -0
- oscura/analyzers/digital/extraction.py +413 -0
- oscura/analyzers/digital/quality.py +878 -0
- oscura/analyzers/digital/signal_quality.py +877 -0
- oscura/analyzers/digital/thresholds.py +708 -0
- oscura/analyzers/digital/timing.py +1104 -0
- oscura/analyzers/eye/__init__.py +46 -0
- oscura/analyzers/eye/diagram.py +434 -0
- oscura/analyzers/eye/metrics.py +555 -0
- oscura/analyzers/jitter/__init__.py +83 -0
- oscura/analyzers/jitter/ber.py +333 -0
- oscura/analyzers/jitter/decomposition.py +759 -0
- oscura/analyzers/jitter/measurements.py +413 -0
- oscura/analyzers/jitter/spectrum.py +220 -0
- oscura/analyzers/measurements.py +40 -0
- oscura/analyzers/packet/__init__.py +171 -0
- oscura/analyzers/packet/daq.py +1077 -0
- oscura/analyzers/packet/metrics.py +437 -0
- oscura/analyzers/packet/parser.py +327 -0
- oscura/analyzers/packet/payload.py +2156 -0
- oscura/analyzers/packet/payload_analysis.py +1312 -0
- oscura/analyzers/packet/payload_extraction.py +236 -0
- oscura/analyzers/packet/payload_patterns.py +670 -0
- oscura/analyzers/packet/stream.py +359 -0
- oscura/analyzers/patterns/__init__.py +266 -0
- oscura/analyzers/patterns/clustering.py +1036 -0
- oscura/analyzers/patterns/discovery.py +539 -0
- oscura/analyzers/patterns/learning.py +797 -0
- oscura/analyzers/patterns/matching.py +1091 -0
- oscura/analyzers/patterns/periodic.py +650 -0
- oscura/analyzers/patterns/sequences.py +767 -0
- oscura/analyzers/power/__init__.py +116 -0
- oscura/analyzers/power/ac_power.py +391 -0
- oscura/analyzers/power/basic.py +383 -0
- oscura/analyzers/power/conduction.py +314 -0
- oscura/analyzers/power/efficiency.py +297 -0
- oscura/analyzers/power/ripple.py +356 -0
- oscura/analyzers/power/soa.py +372 -0
- oscura/analyzers/power/switching.py +479 -0
- oscura/analyzers/protocol/__init__.py +150 -0
- oscura/analyzers/protocols/__init__.py +150 -0
- oscura/analyzers/protocols/base.py +500 -0
- oscura/analyzers/protocols/can.py +620 -0
- oscura/analyzers/protocols/can_fd.py +448 -0
- oscura/analyzers/protocols/flexray.py +405 -0
- oscura/analyzers/protocols/hdlc.py +399 -0
- oscura/analyzers/protocols/i2c.py +368 -0
- oscura/analyzers/protocols/i2s.py +296 -0
- oscura/analyzers/protocols/jtag.py +393 -0
- oscura/analyzers/protocols/lin.py +445 -0
- oscura/analyzers/protocols/manchester.py +333 -0
- oscura/analyzers/protocols/onewire.py +501 -0
- oscura/analyzers/protocols/spi.py +334 -0
- oscura/analyzers/protocols/swd.py +325 -0
- oscura/analyzers/protocols/uart.py +393 -0
- oscura/analyzers/protocols/usb.py +495 -0
- oscura/analyzers/signal_integrity/__init__.py +63 -0
- oscura/analyzers/signal_integrity/embedding.py +294 -0
- oscura/analyzers/signal_integrity/equalization.py +370 -0
- oscura/analyzers/signal_integrity/sparams.py +484 -0
- oscura/analyzers/spectral/__init__.py +53 -0
- oscura/analyzers/spectral/chunked.py +273 -0
- oscura/analyzers/spectral/chunked_fft.py +571 -0
- oscura/analyzers/spectral/chunked_wavelet.py +391 -0
- oscura/analyzers/spectral/fft.py +92 -0
- oscura/analyzers/statistical/__init__.py +250 -0
- oscura/analyzers/statistical/checksum.py +923 -0
- oscura/analyzers/statistical/chunked_corr.py +228 -0
- oscura/analyzers/statistical/classification.py +778 -0
- oscura/analyzers/statistical/entropy.py +1113 -0
- oscura/analyzers/statistical/ngrams.py +614 -0
- oscura/analyzers/statistics/__init__.py +119 -0
- oscura/analyzers/statistics/advanced.py +885 -0
- oscura/analyzers/statistics/basic.py +263 -0
- oscura/analyzers/statistics/correlation.py +630 -0
- oscura/analyzers/statistics/distribution.py +298 -0
- oscura/analyzers/statistics/outliers.py +463 -0
- oscura/analyzers/statistics/streaming.py +93 -0
- oscura/analyzers/statistics/trend.py +520 -0
- oscura/analyzers/validation.py +598 -0
- oscura/analyzers/waveform/__init__.py +36 -0
- oscura/analyzers/waveform/measurements.py +943 -0
- oscura/analyzers/waveform/measurements_with_uncertainty.py +371 -0
- oscura/analyzers/waveform/spectral.py +1689 -0
- oscura/analyzers/waveform/wavelets.py +298 -0
- oscura/api/__init__.py +62 -0
- oscura/api/dsl.py +538 -0
- oscura/api/fluent.py +571 -0
- oscura/api/operators.py +498 -0
- oscura/api/optimization.py +392 -0
- oscura/api/profiling.py +396 -0
- oscura/automotive/__init__.py +73 -0
- oscura/automotive/can/__init__.py +52 -0
- oscura/automotive/can/analysis.py +356 -0
- oscura/automotive/can/checksum.py +250 -0
- oscura/automotive/can/correlation.py +212 -0
- oscura/automotive/can/discovery.py +355 -0
- oscura/automotive/can/message_wrapper.py +375 -0
- oscura/automotive/can/models.py +385 -0
- oscura/automotive/can/patterns.py +381 -0
- oscura/automotive/can/session.py +452 -0
- oscura/automotive/can/state_machine.py +300 -0
- oscura/automotive/can/stimulus_response.py +461 -0
- oscura/automotive/dbc/__init__.py +15 -0
- oscura/automotive/dbc/generator.py +156 -0
- oscura/automotive/dbc/parser.py +146 -0
- oscura/automotive/dtc/__init__.py +30 -0
- oscura/automotive/dtc/database.py +3036 -0
- oscura/automotive/j1939/__init__.py +14 -0
- oscura/automotive/j1939/decoder.py +745 -0
- oscura/automotive/loaders/__init__.py +35 -0
- oscura/automotive/loaders/asc.py +98 -0
- oscura/automotive/loaders/blf.py +77 -0
- oscura/automotive/loaders/csv_can.py +136 -0
- oscura/automotive/loaders/dispatcher.py +136 -0
- oscura/automotive/loaders/mdf.py +331 -0
- oscura/automotive/loaders/pcap.py +132 -0
- oscura/automotive/obd/__init__.py +14 -0
- oscura/automotive/obd/decoder.py +707 -0
- oscura/automotive/uds/__init__.py +48 -0
- oscura/automotive/uds/decoder.py +265 -0
- oscura/automotive/uds/models.py +64 -0
- oscura/automotive/visualization.py +369 -0
- oscura/batch/__init__.py +55 -0
- oscura/batch/advanced.py +627 -0
- oscura/batch/aggregate.py +300 -0
- oscura/batch/analyze.py +139 -0
- oscura/batch/logging.py +487 -0
- oscura/batch/metrics.py +556 -0
- oscura/builders/__init__.py +41 -0
- oscura/builders/signal_builder.py +1131 -0
- oscura/cli/__init__.py +14 -0
- oscura/cli/batch.py +339 -0
- oscura/cli/characterize.py +273 -0
- oscura/cli/compare.py +775 -0
- oscura/cli/decode.py +551 -0
- oscura/cli/main.py +247 -0
- oscura/cli/shell.py +350 -0
- oscura/comparison/__init__.py +66 -0
- oscura/comparison/compare.py +397 -0
- oscura/comparison/golden.py +487 -0
- oscura/comparison/limits.py +391 -0
- oscura/comparison/mask.py +434 -0
- oscura/comparison/trace_diff.py +30 -0
- oscura/comparison/visualization.py +481 -0
- oscura/compliance/__init__.py +70 -0
- oscura/compliance/advanced.py +756 -0
- oscura/compliance/masks.py +363 -0
- oscura/compliance/reporting.py +483 -0
- oscura/compliance/testing.py +298 -0
- oscura/component/__init__.py +38 -0
- oscura/component/impedance.py +365 -0
- oscura/component/reactive.py +598 -0
- oscura/component/transmission_line.py +312 -0
- oscura/config/__init__.py +191 -0
- oscura/config/defaults.py +254 -0
- oscura/config/loader.py +348 -0
- oscura/config/memory.py +271 -0
- oscura/config/migration.py +458 -0
- oscura/config/pipeline.py +1077 -0
- oscura/config/preferences.py +530 -0
- oscura/config/protocol.py +875 -0
- oscura/config/schema.py +713 -0
- oscura/config/settings.py +420 -0
- oscura/config/thresholds.py +599 -0
- oscura/convenience.py +457 -0
- oscura/core/__init__.py +299 -0
- oscura/core/audit.py +457 -0
- oscura/core/backend_selector.py +405 -0
- oscura/core/cache.py +590 -0
- oscura/core/cancellation.py +439 -0
- oscura/core/confidence.py +225 -0
- oscura/core/config.py +506 -0
- oscura/core/correlation.py +216 -0
- oscura/core/cross_domain.py +422 -0
- oscura/core/debug.py +301 -0
- oscura/core/edge_cases.py +541 -0
- oscura/core/exceptions.py +535 -0
- oscura/core/gpu_backend.py +523 -0
- oscura/core/lazy.py +832 -0
- oscura/core/log_query.py +540 -0
- oscura/core/logging.py +931 -0
- oscura/core/logging_advanced.py +952 -0
- oscura/core/memoize.py +171 -0
- oscura/core/memory_check.py +274 -0
- oscura/core/memory_guard.py +290 -0
- oscura/core/memory_limits.py +336 -0
- oscura/core/memory_monitor.py +453 -0
- oscura/core/memory_progress.py +465 -0
- oscura/core/memory_warnings.py +315 -0
- oscura/core/numba_backend.py +362 -0
- oscura/core/performance.py +352 -0
- oscura/core/progress.py +524 -0
- oscura/core/provenance.py +358 -0
- oscura/core/results.py +331 -0
- oscura/core/types.py +504 -0
- oscura/core/uncertainty.py +383 -0
- oscura/discovery/__init__.py +52 -0
- oscura/discovery/anomaly_detector.py +672 -0
- oscura/discovery/auto_decoder.py +415 -0
- oscura/discovery/comparison.py +497 -0
- oscura/discovery/quality_validator.py +528 -0
- oscura/discovery/signal_detector.py +769 -0
- oscura/dsl/__init__.py +73 -0
- oscura/dsl/commands.py +246 -0
- oscura/dsl/interpreter.py +455 -0
- oscura/dsl/parser.py +689 -0
- oscura/dsl/repl.py +172 -0
- oscura/exceptions.py +59 -0
- oscura/exploratory/__init__.py +111 -0
- oscura/exploratory/error_recovery.py +642 -0
- oscura/exploratory/fuzzy.py +513 -0
- oscura/exploratory/fuzzy_advanced.py +786 -0
- oscura/exploratory/legacy.py +831 -0
- oscura/exploratory/parse.py +358 -0
- oscura/exploratory/recovery.py +275 -0
- oscura/exploratory/sync.py +382 -0
- oscura/exploratory/unknown.py +707 -0
- oscura/export/__init__.py +25 -0
- oscura/export/wireshark/README.md +265 -0
- oscura/export/wireshark/__init__.py +47 -0
- oscura/export/wireshark/generator.py +312 -0
- oscura/export/wireshark/lua_builder.py +159 -0
- oscura/export/wireshark/templates/dissector.lua.j2 +92 -0
- oscura/export/wireshark/type_mapping.py +165 -0
- oscura/export/wireshark/validator.py +105 -0
- oscura/exporters/__init__.py +94 -0
- oscura/exporters/csv.py +303 -0
- oscura/exporters/exporters.py +44 -0
- oscura/exporters/hdf5.py +219 -0
- oscura/exporters/html_export.py +701 -0
- oscura/exporters/json_export.py +291 -0
- oscura/exporters/markdown_export.py +367 -0
- oscura/exporters/matlab_export.py +354 -0
- oscura/exporters/npz_export.py +219 -0
- oscura/exporters/spice_export.py +210 -0
- oscura/extensibility/__init__.py +131 -0
- oscura/extensibility/docs.py +752 -0
- oscura/extensibility/extensions.py +1125 -0
- oscura/extensibility/logging.py +259 -0
- oscura/extensibility/measurements.py +485 -0
- oscura/extensibility/plugins.py +414 -0
- oscura/extensibility/registry.py +346 -0
- oscura/extensibility/templates.py +913 -0
- oscura/extensibility/validation.py +651 -0
- oscura/filtering/__init__.py +89 -0
- oscura/filtering/base.py +563 -0
- oscura/filtering/convenience.py +564 -0
- oscura/filtering/design.py +725 -0
- oscura/filtering/filters.py +32 -0
- oscura/filtering/introspection.py +605 -0
- oscura/guidance/__init__.py +24 -0
- oscura/guidance/recommender.py +429 -0
- oscura/guidance/wizard.py +518 -0
- oscura/inference/__init__.py +251 -0
- oscura/inference/active_learning/README.md +153 -0
- oscura/inference/active_learning/__init__.py +38 -0
- oscura/inference/active_learning/lstar.py +257 -0
- oscura/inference/active_learning/observation_table.py +230 -0
- oscura/inference/active_learning/oracle.py +78 -0
- oscura/inference/active_learning/teachers/__init__.py +15 -0
- oscura/inference/active_learning/teachers/simulator.py +192 -0
- oscura/inference/adaptive_tuning.py +453 -0
- oscura/inference/alignment.py +653 -0
- oscura/inference/bayesian.py +943 -0
- oscura/inference/binary.py +1016 -0
- oscura/inference/crc_reverse.py +711 -0
- oscura/inference/logic.py +288 -0
- oscura/inference/message_format.py +1305 -0
- oscura/inference/protocol.py +417 -0
- oscura/inference/protocol_dsl.py +1084 -0
- oscura/inference/protocol_library.py +1230 -0
- oscura/inference/sequences.py +809 -0
- oscura/inference/signal_intelligence.py +1509 -0
- oscura/inference/spectral.py +215 -0
- oscura/inference/state_machine.py +634 -0
- oscura/inference/stream.py +918 -0
- oscura/integrations/__init__.py +59 -0
- oscura/integrations/llm.py +1827 -0
- oscura/jupyter/__init__.py +32 -0
- oscura/jupyter/display.py +268 -0
- oscura/jupyter/magic.py +334 -0
- oscura/loaders/__init__.py +526 -0
- oscura/loaders/binary.py +69 -0
- oscura/loaders/configurable.py +1255 -0
- oscura/loaders/csv.py +26 -0
- oscura/loaders/csv_loader.py +473 -0
- oscura/loaders/hdf5.py +9 -0
- oscura/loaders/hdf5_loader.py +510 -0
- oscura/loaders/lazy.py +370 -0
- oscura/loaders/mmap_loader.py +583 -0
- oscura/loaders/numpy_loader.py +436 -0
- oscura/loaders/pcap.py +432 -0
- oscura/loaders/preprocessing.py +368 -0
- oscura/loaders/rigol.py +287 -0
- oscura/loaders/sigrok.py +321 -0
- oscura/loaders/tdms.py +367 -0
- oscura/loaders/tektronix.py +711 -0
- oscura/loaders/validation.py +584 -0
- oscura/loaders/vcd.py +464 -0
- oscura/loaders/wav.py +233 -0
- oscura/math/__init__.py +45 -0
- oscura/math/arithmetic.py +824 -0
- oscura/math/interpolation.py +413 -0
- oscura/onboarding/__init__.py +39 -0
- oscura/onboarding/help.py +498 -0
- oscura/onboarding/tutorials.py +405 -0
- oscura/onboarding/wizard.py +466 -0
- oscura/optimization/__init__.py +19 -0
- oscura/optimization/parallel.py +440 -0
- oscura/optimization/search.py +532 -0
- oscura/pipeline/__init__.py +43 -0
- oscura/pipeline/base.py +338 -0
- oscura/pipeline/composition.py +242 -0
- oscura/pipeline/parallel.py +448 -0
- oscura/pipeline/pipeline.py +375 -0
- oscura/pipeline/reverse_engineering.py +1119 -0
- oscura/plugins/__init__.py +122 -0
- oscura/plugins/base.py +272 -0
- oscura/plugins/cli.py +497 -0
- oscura/plugins/discovery.py +411 -0
- oscura/plugins/isolation.py +418 -0
- oscura/plugins/lifecycle.py +959 -0
- oscura/plugins/manager.py +493 -0
- oscura/plugins/registry.py +421 -0
- oscura/plugins/versioning.py +372 -0
- oscura/py.typed +0 -0
- oscura/quality/__init__.py +65 -0
- oscura/quality/ensemble.py +740 -0
- oscura/quality/explainer.py +338 -0
- oscura/quality/scoring.py +616 -0
- oscura/quality/warnings.py +456 -0
- oscura/reporting/__init__.py +248 -0
- oscura/reporting/advanced.py +1234 -0
- oscura/reporting/analyze.py +448 -0
- oscura/reporting/argument_preparer.py +596 -0
- oscura/reporting/auto_report.py +507 -0
- oscura/reporting/batch.py +615 -0
- oscura/reporting/chart_selection.py +223 -0
- oscura/reporting/comparison.py +330 -0
- oscura/reporting/config.py +615 -0
- oscura/reporting/content/__init__.py +39 -0
- oscura/reporting/content/executive.py +127 -0
- oscura/reporting/content/filtering.py +191 -0
- oscura/reporting/content/minimal.py +257 -0
- oscura/reporting/content/verbosity.py +162 -0
- oscura/reporting/core.py +508 -0
- oscura/reporting/core_formats/__init__.py +17 -0
- oscura/reporting/core_formats/multi_format.py +210 -0
- oscura/reporting/engine.py +836 -0
- oscura/reporting/export.py +366 -0
- oscura/reporting/formatting/__init__.py +129 -0
- oscura/reporting/formatting/emphasis.py +81 -0
- oscura/reporting/formatting/numbers.py +403 -0
- oscura/reporting/formatting/standards.py +55 -0
- oscura/reporting/formatting.py +466 -0
- oscura/reporting/html.py +578 -0
- oscura/reporting/index.py +590 -0
- oscura/reporting/multichannel.py +296 -0
- oscura/reporting/output.py +379 -0
- oscura/reporting/pdf.py +373 -0
- oscura/reporting/plots.py +731 -0
- oscura/reporting/pptx_export.py +360 -0
- oscura/reporting/renderers/__init__.py +11 -0
- oscura/reporting/renderers/pdf.py +94 -0
- oscura/reporting/sections.py +471 -0
- oscura/reporting/standards.py +680 -0
- oscura/reporting/summary_generator.py +368 -0
- oscura/reporting/tables.py +397 -0
- oscura/reporting/template_system.py +724 -0
- oscura/reporting/templates/__init__.py +15 -0
- oscura/reporting/templates/definition.py +205 -0
- oscura/reporting/templates/index.html +649 -0
- oscura/reporting/templates/index.md +173 -0
- oscura/schemas/__init__.py +158 -0
- oscura/schemas/bus_configuration.json +322 -0
- oscura/schemas/device_mapping.json +182 -0
- oscura/schemas/packet_format.json +418 -0
- oscura/schemas/protocol_definition.json +363 -0
- oscura/search/__init__.py +16 -0
- oscura/search/anomaly.py +292 -0
- oscura/search/context.py +149 -0
- oscura/search/pattern.py +160 -0
- oscura/session/__init__.py +34 -0
- oscura/session/annotations.py +289 -0
- oscura/session/history.py +313 -0
- oscura/session/session.py +445 -0
- oscura/streaming/__init__.py +43 -0
- oscura/streaming/chunked.py +611 -0
- oscura/streaming/progressive.py +393 -0
- oscura/streaming/realtime.py +622 -0
- oscura/testing/__init__.py +54 -0
- oscura/testing/synthetic.py +808 -0
- oscura/triggering/__init__.py +68 -0
- oscura/triggering/base.py +229 -0
- oscura/triggering/edge.py +353 -0
- oscura/triggering/pattern.py +344 -0
- oscura/triggering/pulse.py +581 -0
- oscura/triggering/window.py +453 -0
- oscura/ui/__init__.py +48 -0
- oscura/ui/formatters.py +526 -0
- oscura/ui/progressive_display.py +340 -0
- oscura/utils/__init__.py +99 -0
- oscura/utils/autodetect.py +338 -0
- oscura/utils/buffer.py +389 -0
- oscura/utils/lazy.py +407 -0
- oscura/utils/lazy_imports.py +147 -0
- oscura/utils/memory.py +836 -0
- oscura/utils/memory_advanced.py +1326 -0
- oscura/utils/memory_extensions.py +465 -0
- oscura/utils/progressive.py +352 -0
- oscura/utils/windowing.py +362 -0
- oscura/visualization/__init__.py +321 -0
- oscura/visualization/accessibility.py +526 -0
- oscura/visualization/annotations.py +374 -0
- oscura/visualization/axis_scaling.py +305 -0
- oscura/visualization/colors.py +453 -0
- oscura/visualization/digital.py +337 -0
- oscura/visualization/eye.py +420 -0
- oscura/visualization/histogram.py +281 -0
- oscura/visualization/interactive.py +858 -0
- oscura/visualization/jitter.py +702 -0
- oscura/visualization/keyboard.py +394 -0
- oscura/visualization/layout.py +365 -0
- oscura/visualization/optimization.py +1028 -0
- oscura/visualization/palettes.py +446 -0
- oscura/visualization/plot.py +92 -0
- oscura/visualization/power.py +290 -0
- oscura/visualization/power_extended.py +626 -0
- oscura/visualization/presets.py +467 -0
- oscura/visualization/protocols.py +932 -0
- oscura/visualization/render.py +207 -0
- oscura/visualization/rendering.py +444 -0
- oscura/visualization/reverse_engineering.py +791 -0
- oscura/visualization/signal_integrity.py +808 -0
- oscura/visualization/specialized.py +553 -0
- oscura/visualization/spectral.py +811 -0
- oscura/visualization/styles.py +381 -0
- oscura/visualization/thumbnails.py +311 -0
- oscura/visualization/time_axis.py +351 -0
- oscura/visualization/waveform.py +367 -0
- oscura/workflow/__init__.py +13 -0
- oscura/workflow/dag.py +377 -0
- oscura/workflows/__init__.py +58 -0
- oscura/workflows/compliance.py +280 -0
- oscura/workflows/digital.py +272 -0
- oscura/workflows/multi_trace.py +502 -0
- oscura/workflows/power.py +178 -0
- oscura/workflows/protocol.py +492 -0
- oscura/workflows/reverse_engineering.py +639 -0
- oscura/workflows/signal_integrity.py +227 -0
- oscura-0.1.0.dist-info/METADATA +300 -0
- oscura-0.1.0.dist-info/RECORD +463 -0
- oscura-0.1.0.dist-info/entry_points.txt +2 -0
- {oscura-0.0.1.dist-info → oscura-0.1.0.dist-info}/licenses/LICENSE +1 -1
- oscura-0.0.1.dist-info/METADATA +0 -63
- oscura-0.0.1.dist-info/RECORD +0 -5
- {oscura-0.0.1.dist-info → oscura-0.1.0.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,1234 @@
|
|
|
1
|
+
"""Advanced reporting features for TraceKit.
|
|
2
|
+
|
|
3
|
+
This module provides advanced reporting capabilities including interactive
|
|
4
|
+
reports, scheduled generation, distribution, versioning, and compliance.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import re
|
|
13
|
+
import uuid
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from datetime import datetime, timedelta
|
|
16
|
+
from enum import Enum, auto
|
|
17
|
+
from typing import TYPE_CHECKING, Any
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from collections.abc import Callable
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# =============================================================================
|
|
27
|
+
# =============================================================================
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class TemplateField:
|
|
32
|
+
"""Customizable template field.
|
|
33
|
+
|
|
34
|
+
Attributes:
|
|
35
|
+
name: Field identifier
|
|
36
|
+
type: Field type (text, number, image, table, chart)
|
|
37
|
+
default: Default value
|
|
38
|
+
required: Whether field is required
|
|
39
|
+
validation: Validation rule (regex pattern)
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
name: str
|
|
43
|
+
type: str = "text"
|
|
44
|
+
default: Any = None
|
|
45
|
+
required: bool = False
|
|
46
|
+
validation: str | None = None
|
|
47
|
+
description: str = ""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class CustomTemplate:
|
|
52
|
+
"""Customizable report template.
|
|
53
|
+
|
|
54
|
+
Attributes:
|
|
55
|
+
name: Template name
|
|
56
|
+
version: Template version
|
|
57
|
+
fields: List of customizable fields
|
|
58
|
+
layout: Layout configuration
|
|
59
|
+
styles: CSS/style overrides
|
|
60
|
+
includes: Included partial templates
|
|
61
|
+
|
|
62
|
+
Example:
|
|
63
|
+
>>> template = CustomTemplate(
|
|
64
|
+
... name="compliance_report",
|
|
65
|
+
... fields=[
|
|
66
|
+
... TemplateField("company_name", required=True),
|
|
67
|
+
... TemplateField("logo", type="image")
|
|
68
|
+
... ]
|
|
69
|
+
... )
|
|
70
|
+
|
|
71
|
+
References:
|
|
72
|
+
REPORT-011: Report Customization Templates
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
name: str
|
|
76
|
+
version: str = "1.0.0"
|
|
77
|
+
fields: list[TemplateField] = field(default_factory=list)
|
|
78
|
+
layout: dict[str, Any] = field(default_factory=dict)
|
|
79
|
+
styles: dict[str, str] = field(default_factory=dict)
|
|
80
|
+
includes: list[str] = field(default_factory=list)
|
|
81
|
+
description: str = ""
|
|
82
|
+
|
|
83
|
+
def validate_data(self, data: dict[str, Any]) -> tuple[bool, list[str]]:
|
|
84
|
+
"""Validate data against template fields.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
data: Data dictionary
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
Tuple of (is_valid, list of errors)
|
|
91
|
+
"""
|
|
92
|
+
errors = []
|
|
93
|
+
for f in self.fields:
|
|
94
|
+
if f.required and f.name not in data:
|
|
95
|
+
errors.append(f"Required field '{f.name}' missing")
|
|
96
|
+
elif f.name in data and f.validation:
|
|
97
|
+
if not re.match(f.validation, str(data[f.name])):
|
|
98
|
+
errors.append(f"Field '{f.name}' failed validation")
|
|
99
|
+
return len(errors) == 0, errors
|
|
100
|
+
|
|
101
|
+
def render(self, data: dict[str, Any]) -> str:
|
|
102
|
+
"""Render template with data.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
data: Data dictionary
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
Rendered content
|
|
109
|
+
"""
|
|
110
|
+
# Simple placeholder substitution
|
|
111
|
+
content = self.layout.get("template", "")
|
|
112
|
+
for f in self.fields:
|
|
113
|
+
placeholder = f"{{{{{f.name}}}}}"
|
|
114
|
+
value = data.get(f.name, f.default or "")
|
|
115
|
+
content = content.replace(placeholder, str(value))
|
|
116
|
+
return content # type: ignore[no-any-return]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# =============================================================================
|
|
120
|
+
# =============================================================================
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class InteractiveElementType(Enum):
|
|
124
|
+
"""Types of interactive elements."""
|
|
125
|
+
|
|
126
|
+
ZOOMABLE_CHART = auto()
|
|
127
|
+
COLLAPSIBLE_SECTION = auto()
|
|
128
|
+
FILTER_DROPDOWN = auto()
|
|
129
|
+
SORTABLE_TABLE = auto()
|
|
130
|
+
TOOLTIP = auto()
|
|
131
|
+
DRILL_DOWN = auto()
|
|
132
|
+
TOGGLE = auto()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass
|
|
136
|
+
class InteractiveElement:
|
|
137
|
+
"""Interactive element for HTML reports.
|
|
138
|
+
|
|
139
|
+
Attributes:
|
|
140
|
+
id: Element ID
|
|
141
|
+
type: Element type
|
|
142
|
+
data: Element data
|
|
143
|
+
options: Configuration options
|
|
144
|
+
script: JavaScript code for interactivity
|
|
145
|
+
|
|
146
|
+
Example:
|
|
147
|
+
>>> element = InteractiveElement(
|
|
148
|
+
... id="chart1",
|
|
149
|
+
... type=InteractiveElementType.ZOOMABLE_CHART,
|
|
150
|
+
... data=chart_data
|
|
151
|
+
... )
|
|
152
|
+
|
|
153
|
+
References:
|
|
154
|
+
REPORT-012: Interactive Report Elements
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
id: str
|
|
158
|
+
type: InteractiveElementType
|
|
159
|
+
data: Any = None
|
|
160
|
+
options: dict[str, Any] = field(default_factory=dict)
|
|
161
|
+
script: str = ""
|
|
162
|
+
|
|
163
|
+
def to_html(self) -> str:
|
|
164
|
+
"""Generate HTML for interactive element."""
|
|
165
|
+
html_parts = [f'<div id="{self.id}" class="interactive-{self.type.name.lower()}">']
|
|
166
|
+
|
|
167
|
+
if self.type == InteractiveElementType.COLLAPSIBLE_SECTION:
|
|
168
|
+
html_parts.append(f"""
|
|
169
|
+
<button class="collapsible" onclick="toggleSection('{self.id}')">
|
|
170
|
+
{self.options.get("title", "Section")}
|
|
171
|
+
</button>
|
|
172
|
+
<div class="content" style="display:none;">
|
|
173
|
+
{self.data or ""}
|
|
174
|
+
</div>
|
|
175
|
+
""")
|
|
176
|
+
elif self.type == InteractiveElementType.SORTABLE_TABLE:
|
|
177
|
+
html_parts.append(f"""
|
|
178
|
+
<table class="sortable" data-sort-enabled="true">
|
|
179
|
+
{self.data or ""}
|
|
180
|
+
</table>
|
|
181
|
+
""")
|
|
182
|
+
elif self.type == InteractiveElementType.TOOLTIP:
|
|
183
|
+
html_parts.append(f'''
|
|
184
|
+
<span class="tooltip" data-tooltip="{self.options.get("text", "")}">
|
|
185
|
+
{self.data or ""}
|
|
186
|
+
</span>
|
|
187
|
+
''')
|
|
188
|
+
else:
|
|
189
|
+
html_parts.append(str(self.data or ""))
|
|
190
|
+
|
|
191
|
+
html_parts.append("</div>")
|
|
192
|
+
return "\n".join(html_parts)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# =============================================================================
|
|
196
|
+
# =============================================================================
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@dataclass
|
|
200
|
+
class Annotation:
|
|
201
|
+
"""Report annotation.
|
|
202
|
+
|
|
203
|
+
Attributes:
|
|
204
|
+
id: Unique annotation ID
|
|
205
|
+
target: Target element ID or location
|
|
206
|
+
text: Annotation text
|
|
207
|
+
author: Author name
|
|
208
|
+
created: Creation timestamp
|
|
209
|
+
type: Annotation type (note, warning, highlight, etc.)
|
|
210
|
+
position: Position info for placement
|
|
211
|
+
|
|
212
|
+
References:
|
|
213
|
+
REPORT-013: Report Annotations
|
|
214
|
+
"""
|
|
215
|
+
|
|
216
|
+
id: str
|
|
217
|
+
target: str
|
|
218
|
+
text: str
|
|
219
|
+
author: str = ""
|
|
220
|
+
created: datetime = field(default_factory=datetime.now)
|
|
221
|
+
type: str = "note"
|
|
222
|
+
position: dict[str, float] = field(default_factory=dict)
|
|
223
|
+
|
|
224
|
+
def to_dict(self) -> dict[str, Any]:
|
|
225
|
+
"""Convert to dictionary."""
|
|
226
|
+
return {
|
|
227
|
+
"id": self.id,
|
|
228
|
+
"target": self.target,
|
|
229
|
+
"text": self.text,
|
|
230
|
+
"author": self.author,
|
|
231
|
+
"created": self.created.isoformat(),
|
|
232
|
+
"type": self.type,
|
|
233
|
+
"position": self.position,
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class AnnotationManager:
|
|
238
|
+
"""Manager for report annotations.
|
|
239
|
+
|
|
240
|
+
References:
|
|
241
|
+
REPORT-013: Report Annotations
|
|
242
|
+
"""
|
|
243
|
+
|
|
244
|
+
def __init__(self, report_id: str):
|
|
245
|
+
self.report_id = report_id
|
|
246
|
+
self._annotations: list[Annotation] = []
|
|
247
|
+
|
|
248
|
+
def add(self, target: str, text: str, author: str = "", type_: str = "note") -> Annotation:
|
|
249
|
+
"""Add annotation."""
|
|
250
|
+
annotation = Annotation(
|
|
251
|
+
id=str(uuid.uuid4()), target=target, text=text, author=author, type=type_
|
|
252
|
+
)
|
|
253
|
+
self._annotations.append(annotation)
|
|
254
|
+
return annotation
|
|
255
|
+
|
|
256
|
+
def remove(self, annotation_id: str) -> bool:
|
|
257
|
+
"""Remove annotation."""
|
|
258
|
+
for i, ann in enumerate(self._annotations):
|
|
259
|
+
if ann.id == annotation_id:
|
|
260
|
+
del self._annotations[i]
|
|
261
|
+
return True
|
|
262
|
+
return False
|
|
263
|
+
|
|
264
|
+
def get_for_target(self, target: str) -> list[Annotation]:
|
|
265
|
+
"""Get annotations for target."""
|
|
266
|
+
return [a for a in self._annotations if a.target == target]
|
|
267
|
+
|
|
268
|
+
def export(self) -> list[dict[str, Any]]:
|
|
269
|
+
"""Export all annotations."""
|
|
270
|
+
return [a.to_dict() for a in self._annotations]
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# =============================================================================
|
|
274
|
+
# =============================================================================
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class ScheduleFrequency(Enum):
|
|
278
|
+
"""Report schedule frequency."""
|
|
279
|
+
|
|
280
|
+
ONCE = auto()
|
|
281
|
+
HOURLY = auto()
|
|
282
|
+
DAILY = auto()
|
|
283
|
+
WEEKLY = auto()
|
|
284
|
+
MONTHLY = auto()
|
|
285
|
+
CUSTOM = auto()
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
@dataclass
|
|
289
|
+
class ReportSchedule:
|
|
290
|
+
"""Scheduled report configuration.
|
|
291
|
+
|
|
292
|
+
Attributes:
|
|
293
|
+
id: Schedule ID
|
|
294
|
+
report_config: Report configuration
|
|
295
|
+
frequency: Generation frequency
|
|
296
|
+
next_run: Next scheduled run time
|
|
297
|
+
enabled: Whether schedule is active
|
|
298
|
+
recipients: Email recipients
|
|
299
|
+
cron_expression: Cron expression for custom schedules
|
|
300
|
+
|
|
301
|
+
References:
|
|
302
|
+
REPORT-017: Report Scheduling
|
|
303
|
+
"""
|
|
304
|
+
|
|
305
|
+
id: str
|
|
306
|
+
report_config: dict[str, Any]
|
|
307
|
+
frequency: ScheduleFrequency = ScheduleFrequency.DAILY
|
|
308
|
+
next_run: datetime = field(default_factory=datetime.now)
|
|
309
|
+
enabled: bool = True
|
|
310
|
+
recipients: list[str] = field(default_factory=list)
|
|
311
|
+
cron_expression: str | None = None
|
|
312
|
+
|
|
313
|
+
def calculate_next_run(self) -> datetime:
|
|
314
|
+
"""Calculate next run time."""
|
|
315
|
+
now = datetime.now()
|
|
316
|
+
if self.frequency == ScheduleFrequency.HOURLY:
|
|
317
|
+
return now + timedelta(hours=1)
|
|
318
|
+
elif self.frequency == ScheduleFrequency.DAILY:
|
|
319
|
+
return now + timedelta(days=1)
|
|
320
|
+
elif self.frequency == ScheduleFrequency.WEEKLY:
|
|
321
|
+
return now + timedelta(weeks=1)
|
|
322
|
+
elif self.frequency == ScheduleFrequency.MONTHLY:
|
|
323
|
+
return now + timedelta(days=30)
|
|
324
|
+
return now
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
class ReportScheduler:
|
|
328
|
+
"""Report scheduler for automated generation.
|
|
329
|
+
|
|
330
|
+
References:
|
|
331
|
+
REPORT-017: Report Scheduling
|
|
332
|
+
"""
|
|
333
|
+
|
|
334
|
+
def __init__(self): # type: ignore[no-untyped-def]
|
|
335
|
+
self._schedules: dict[str, ReportSchedule] = {}
|
|
336
|
+
self._running = False
|
|
337
|
+
|
|
338
|
+
def add_schedule(
|
|
339
|
+
self,
|
|
340
|
+
report_config: dict[str, Any],
|
|
341
|
+
frequency: ScheduleFrequency,
|
|
342
|
+
recipients: list[str] | None = None,
|
|
343
|
+
) -> str:
|
|
344
|
+
"""Add new schedule."""
|
|
345
|
+
schedule = ReportSchedule(
|
|
346
|
+
id=str(uuid.uuid4()),
|
|
347
|
+
report_config=report_config,
|
|
348
|
+
frequency=frequency,
|
|
349
|
+
recipients=recipients or [],
|
|
350
|
+
)
|
|
351
|
+
self._schedules[schedule.id] = schedule
|
|
352
|
+
return schedule.id
|
|
353
|
+
|
|
354
|
+
def remove_schedule(self, schedule_id: str) -> bool:
|
|
355
|
+
"""Remove schedule."""
|
|
356
|
+
if schedule_id in self._schedules:
|
|
357
|
+
del self._schedules[schedule_id]
|
|
358
|
+
return True
|
|
359
|
+
return False
|
|
360
|
+
|
|
361
|
+
def get_pending(self) -> list[ReportSchedule]:
|
|
362
|
+
"""Get schedules due for execution."""
|
|
363
|
+
now = datetime.now()
|
|
364
|
+
return [s for s in self._schedules.values() if s.enabled and s.next_run <= now]
|
|
365
|
+
|
|
366
|
+
def execute_pending(self, generator: Callable[[dict[str, Any]], Any]) -> list[str]:
|
|
367
|
+
"""Execute pending schedules."""
|
|
368
|
+
executed = []
|
|
369
|
+
for schedule in self.get_pending():
|
|
370
|
+
try:
|
|
371
|
+
generator(schedule.report_config)
|
|
372
|
+
schedule.next_run = schedule.calculate_next_run()
|
|
373
|
+
executed.append(schedule.id)
|
|
374
|
+
except Exception as e:
|
|
375
|
+
logger.error(f"Scheduled report failed: {e}")
|
|
376
|
+
return executed
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
# =============================================================================
|
|
380
|
+
# =============================================================================
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
class DistributionChannel(Enum):
|
|
384
|
+
"""Distribution channels."""
|
|
385
|
+
|
|
386
|
+
EMAIL = auto()
|
|
387
|
+
FILE_SHARE = auto()
|
|
388
|
+
WEBHOOK = auto()
|
|
389
|
+
S3 = auto()
|
|
390
|
+
SFTP = auto()
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
@dataclass
|
|
394
|
+
class DistributionConfig:
|
|
395
|
+
"""Distribution configuration.
|
|
396
|
+
|
|
397
|
+
References:
|
|
398
|
+
REPORT-020: Report Distribution
|
|
399
|
+
"""
|
|
400
|
+
|
|
401
|
+
channel: DistributionChannel
|
|
402
|
+
recipients: list[str] = field(default_factory=list)
|
|
403
|
+
settings: dict[str, Any] = field(default_factory=dict)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
class ReportDistributor:
|
|
407
|
+
"""Distributes reports to configured channels.
|
|
408
|
+
|
|
409
|
+
References:
|
|
410
|
+
REPORT-020: Report Distribution
|
|
411
|
+
"""
|
|
412
|
+
|
|
413
|
+
def __init__(self): # type: ignore[no-untyped-def]
|
|
414
|
+
self._handlers: dict[DistributionChannel, Callable] = {} # type: ignore[type-arg]
|
|
415
|
+
|
|
416
|
+
def register_handler(
|
|
417
|
+
self,
|
|
418
|
+
channel: DistributionChannel,
|
|
419
|
+
handler: Callable[[Path, DistributionConfig], bool],
|
|
420
|
+
) -> None:
|
|
421
|
+
"""Register distribution handler."""
|
|
422
|
+
self._handlers[channel] = handler
|
|
423
|
+
|
|
424
|
+
def distribute(self, report_path: Path, configs: list[DistributionConfig]) -> dict[str, bool]:
|
|
425
|
+
"""Distribute report to all configured channels."""
|
|
426
|
+
results = {}
|
|
427
|
+
for config in configs:
|
|
428
|
+
handler = self._handlers.get(config.channel)
|
|
429
|
+
if handler:
|
|
430
|
+
try:
|
|
431
|
+
results[config.channel.name] = handler(report_path, config)
|
|
432
|
+
except Exception as e:
|
|
433
|
+
logger.error(f"Distribution failed for {config.channel}: {e}")
|
|
434
|
+
results[config.channel.name] = False
|
|
435
|
+
else:
|
|
436
|
+
logger.warning(f"No handler for channel: {config.channel}")
|
|
437
|
+
results[config.channel.name] = False
|
|
438
|
+
return results
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
# =============================================================================
|
|
442
|
+
# =============================================================================
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
@dataclass
|
|
446
|
+
class ArchivedReport:
|
|
447
|
+
"""Archived report metadata.
|
|
448
|
+
|
|
449
|
+
References:
|
|
450
|
+
REPORT-021: Report Archiving
|
|
451
|
+
"""
|
|
452
|
+
|
|
453
|
+
id: str
|
|
454
|
+
name: str
|
|
455
|
+
path: Path
|
|
456
|
+
created: datetime
|
|
457
|
+
size: int
|
|
458
|
+
checksum: str
|
|
459
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
460
|
+
retention_days: int = 365
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
class ReportArchive:
|
|
464
|
+
"""Report archiving system.
|
|
465
|
+
|
|
466
|
+
References:
|
|
467
|
+
REPORT-021: Report Archiving
|
|
468
|
+
"""
|
|
469
|
+
|
|
470
|
+
def __init__(self, archive_dir: Path):
|
|
471
|
+
self.archive_dir = archive_dir
|
|
472
|
+
archive_dir.mkdir(parents=True, exist_ok=True)
|
|
473
|
+
self._index: dict[str, ArchivedReport] = {}
|
|
474
|
+
|
|
475
|
+
def archive(self, report_path: Path, metadata: dict[str, Any] | None = None) -> str:
|
|
476
|
+
"""Archive a report."""
|
|
477
|
+
report_id = str(uuid.uuid4())
|
|
478
|
+
|
|
479
|
+
# Calculate checksum
|
|
480
|
+
with open(report_path, "rb") as f:
|
|
481
|
+
checksum = hashlib.sha256(f.read()).hexdigest()
|
|
482
|
+
|
|
483
|
+
# Copy to archive
|
|
484
|
+
archive_path = self.archive_dir / f"{report_id}_{report_path.name}"
|
|
485
|
+
import shutil
|
|
486
|
+
|
|
487
|
+
shutil.copy2(report_path, archive_path)
|
|
488
|
+
|
|
489
|
+
archived = ArchivedReport(
|
|
490
|
+
id=report_id,
|
|
491
|
+
name=report_path.name,
|
|
492
|
+
path=archive_path,
|
|
493
|
+
created=datetime.now(),
|
|
494
|
+
size=archive_path.stat().st_size,
|
|
495
|
+
checksum=checksum,
|
|
496
|
+
metadata=metadata or {},
|
|
497
|
+
)
|
|
498
|
+
self._index[report_id] = archived
|
|
499
|
+
|
|
500
|
+
logger.info(f"Archived report: {report_id}")
|
|
501
|
+
return report_id
|
|
502
|
+
|
|
503
|
+
def retrieve(self, report_id: str) -> Path | None:
|
|
504
|
+
"""Retrieve archived report."""
|
|
505
|
+
if report_id in self._index:
|
|
506
|
+
return self._index[report_id].path
|
|
507
|
+
return None
|
|
508
|
+
|
|
509
|
+
def cleanup_expired(self) -> int:
|
|
510
|
+
"""Remove expired archives."""
|
|
511
|
+
now = datetime.now()
|
|
512
|
+
removed = 0
|
|
513
|
+
for report_id, archived in list(self._index.items()):
|
|
514
|
+
age = (now - archived.created).days
|
|
515
|
+
if age > archived.retention_days:
|
|
516
|
+
archived.path.unlink(missing_ok=True)
|
|
517
|
+
del self._index[report_id]
|
|
518
|
+
removed += 1
|
|
519
|
+
return removed
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
# =============================================================================
|
|
523
|
+
# =============================================================================
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
@dataclass
|
|
527
|
+
class SearchResult:
|
|
528
|
+
"""Report search result.
|
|
529
|
+
|
|
530
|
+
References:
|
|
531
|
+
REPORT-022: Report Search
|
|
532
|
+
"""
|
|
533
|
+
|
|
534
|
+
report_id: str
|
|
535
|
+
name: str
|
|
536
|
+
score: float
|
|
537
|
+
highlights: list[str] = field(default_factory=list)
|
|
538
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
class ReportSearchIndex:
|
|
542
|
+
"""Full-text search index for reports.
|
|
543
|
+
|
|
544
|
+
References:
|
|
545
|
+
REPORT-022: Report Search
|
|
546
|
+
"""
|
|
547
|
+
|
|
548
|
+
def __init__(self): # type: ignore[no-untyped-def]
|
|
549
|
+
self._index: dict[str, dict[str, Any]] = {}
|
|
550
|
+
|
|
551
|
+
def index_report(self, report_id: str, content: str, metadata: dict[str, Any]) -> None:
|
|
552
|
+
"""Add report to search index."""
|
|
553
|
+
# Simple word-based indexing
|
|
554
|
+
words = set(content.lower().split())
|
|
555
|
+
self._index[report_id] = {
|
|
556
|
+
"words": words,
|
|
557
|
+
"content": content,
|
|
558
|
+
"metadata": metadata,
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
def search(self, query: str, limit: int = 10) -> list[SearchResult]:
|
|
562
|
+
"""Search for reports."""
|
|
563
|
+
query_words = set(query.lower().split())
|
|
564
|
+
results = []
|
|
565
|
+
|
|
566
|
+
for report_id, doc in self._index.items():
|
|
567
|
+
# Simple scoring: intersection of words
|
|
568
|
+
matches = query_words & doc["words"]
|
|
569
|
+
if matches:
|
|
570
|
+
score = len(matches) / len(query_words)
|
|
571
|
+
results.append(
|
|
572
|
+
SearchResult(
|
|
573
|
+
report_id=report_id,
|
|
574
|
+
name=doc["metadata"].get("name", report_id),
|
|
575
|
+
score=score,
|
|
576
|
+
highlights=[f"...{m}..." for m in matches],
|
|
577
|
+
metadata=doc["metadata"],
|
|
578
|
+
)
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
# Sort by score
|
|
582
|
+
results.sort(key=lambda r: r.score, reverse=True)
|
|
583
|
+
return results[:limit]
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
# =============================================================================
|
|
587
|
+
# =============================================================================
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
@dataclass
|
|
591
|
+
class ReportVersion:
|
|
592
|
+
"""Report version entry.
|
|
593
|
+
|
|
594
|
+
References:
|
|
595
|
+
REPORT-023: Report Versioning
|
|
596
|
+
"""
|
|
597
|
+
|
|
598
|
+
version: int
|
|
599
|
+
created: datetime
|
|
600
|
+
author: str
|
|
601
|
+
changes: str
|
|
602
|
+
checksum: str
|
|
603
|
+
path: Path
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
class ReportVersionControl:
|
|
607
|
+
"""Version control for reports.
|
|
608
|
+
|
|
609
|
+
References:
|
|
610
|
+
REPORT-023: Report Versioning
|
|
611
|
+
"""
|
|
612
|
+
|
|
613
|
+
def __init__(self, storage_dir: Path):
|
|
614
|
+
self.storage_dir = storage_dir
|
|
615
|
+
storage_dir.mkdir(parents=True, exist_ok=True)
|
|
616
|
+
self._versions: dict[str, list[ReportVersion]] = {}
|
|
617
|
+
|
|
618
|
+
def commit(self, report_id: str, report_path: Path, author: str, changes: str) -> int:
|
|
619
|
+
"""Commit new version of report."""
|
|
620
|
+
if report_id not in self._versions:
|
|
621
|
+
self._versions[report_id] = []
|
|
622
|
+
|
|
623
|
+
version = len(self._versions[report_id]) + 1
|
|
624
|
+
|
|
625
|
+
# Copy to versioned storage
|
|
626
|
+
version_path = self.storage_dir / f"{report_id}_v{version}{report_path.suffix}"
|
|
627
|
+
import shutil
|
|
628
|
+
|
|
629
|
+
shutil.copy2(report_path, version_path)
|
|
630
|
+
|
|
631
|
+
# Calculate checksum
|
|
632
|
+
with open(version_path, "rb") as f:
|
|
633
|
+
checksum = hashlib.sha256(f.read()).hexdigest()
|
|
634
|
+
|
|
635
|
+
entry = ReportVersion(
|
|
636
|
+
version=version,
|
|
637
|
+
created=datetime.now(),
|
|
638
|
+
author=author,
|
|
639
|
+
changes=changes,
|
|
640
|
+
checksum=checksum,
|
|
641
|
+
path=version_path,
|
|
642
|
+
)
|
|
643
|
+
self._versions[report_id].append(entry)
|
|
644
|
+
|
|
645
|
+
logger.info(f"Committed {report_id} version {version}")
|
|
646
|
+
return version
|
|
647
|
+
|
|
648
|
+
def get_version(self, report_id: str, version: int) -> Path | None:
|
|
649
|
+
"""Get specific version of report."""
|
|
650
|
+
if report_id in self._versions:
|
|
651
|
+
for v in self._versions[report_id]:
|
|
652
|
+
if v.version == version:
|
|
653
|
+
return v.path
|
|
654
|
+
return None
|
|
655
|
+
|
|
656
|
+
def get_history(self, report_id: str) -> list[ReportVersion]:
|
|
657
|
+
"""Get version history."""
|
|
658
|
+
return self._versions.get(report_id, [])
|
|
659
|
+
|
|
660
|
+
def diff(self, report_id: str, v1: int, v2: int) -> str:
|
|
661
|
+
"""Get diff between versions."""
|
|
662
|
+
path1 = self.get_version(report_id, v1)
|
|
663
|
+
path2 = self.get_version(report_id, v2)
|
|
664
|
+
|
|
665
|
+
if not path1 or not path2:
|
|
666
|
+
return "Version not found"
|
|
667
|
+
|
|
668
|
+
# Simple text diff
|
|
669
|
+
with open(path1) as f1, open(path2) as f2:
|
|
670
|
+
lines1 = f1.readlines()
|
|
671
|
+
lines2 = f2.readlines()
|
|
672
|
+
|
|
673
|
+
import difflib
|
|
674
|
+
|
|
675
|
+
diff = difflib.unified_diff(lines1, lines2, lineterm="")
|
|
676
|
+
return "\n".join(diff)
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
# =============================================================================
|
|
680
|
+
# =============================================================================
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
class ApprovalStatus(Enum):
|
|
684
|
+
"""Approval status."""
|
|
685
|
+
|
|
686
|
+
DRAFT = auto()
|
|
687
|
+
PENDING_REVIEW = auto()
|
|
688
|
+
APPROVED = auto()
|
|
689
|
+
REJECTED = auto()
|
|
690
|
+
PUBLISHED = auto()
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
@dataclass
|
|
694
|
+
class ApprovalRecord:
|
|
695
|
+
"""Approval workflow record.
|
|
696
|
+
|
|
697
|
+
References:
|
|
698
|
+
REPORT-024: Report Approval Workflow
|
|
699
|
+
"""
|
|
700
|
+
|
|
701
|
+
report_id: str
|
|
702
|
+
status: ApprovalStatus = ApprovalStatus.DRAFT
|
|
703
|
+
submitter: str = ""
|
|
704
|
+
reviewer: str | None = None
|
|
705
|
+
submitted_at: datetime | None = None
|
|
706
|
+
reviewed_at: datetime | None = None
|
|
707
|
+
comments: str = ""
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
class ApprovalWorkflow:
|
|
711
|
+
"""Report approval workflow manager.
|
|
712
|
+
|
|
713
|
+
References:
|
|
714
|
+
REPORT-024: Report Approval Workflow
|
|
715
|
+
"""
|
|
716
|
+
|
|
717
|
+
def __init__(self): # type: ignore[no-untyped-def]
|
|
718
|
+
self._records: dict[str, ApprovalRecord] = {}
|
|
719
|
+
self._callbacks: dict[ApprovalStatus, list[Callable]] = {} # type: ignore[type-arg]
|
|
720
|
+
|
|
721
|
+
def submit_for_review(self, report_id: str, submitter: str) -> ApprovalRecord:
|
|
722
|
+
"""Submit report for review."""
|
|
723
|
+
record = ApprovalRecord(
|
|
724
|
+
report_id=report_id,
|
|
725
|
+
status=ApprovalStatus.PENDING_REVIEW,
|
|
726
|
+
submitter=submitter,
|
|
727
|
+
submitted_at=datetime.now(),
|
|
728
|
+
)
|
|
729
|
+
self._records[report_id] = record
|
|
730
|
+
self._trigger_callbacks(ApprovalStatus.PENDING_REVIEW, record)
|
|
731
|
+
return record
|
|
732
|
+
|
|
733
|
+
def approve(self, report_id: str, reviewer: str, comments: str = "") -> ApprovalRecord:
|
|
734
|
+
"""Approve report."""
|
|
735
|
+
record = self._records.get(report_id)
|
|
736
|
+
if not record:
|
|
737
|
+
raise ValueError(f"Report {report_id} not in workflow")
|
|
738
|
+
|
|
739
|
+
record.status = ApprovalStatus.APPROVED
|
|
740
|
+
record.reviewer = reviewer
|
|
741
|
+
record.reviewed_at = datetime.now()
|
|
742
|
+
record.comments = comments
|
|
743
|
+
self._trigger_callbacks(ApprovalStatus.APPROVED, record)
|
|
744
|
+
return record
|
|
745
|
+
|
|
746
|
+
def reject(self, report_id: str, reviewer: str, comments: str) -> ApprovalRecord:
|
|
747
|
+
"""Reject report."""
|
|
748
|
+
record = self._records.get(report_id)
|
|
749
|
+
if not record:
|
|
750
|
+
raise ValueError(f"Report {report_id} not in workflow")
|
|
751
|
+
|
|
752
|
+
record.status = ApprovalStatus.REJECTED
|
|
753
|
+
record.reviewer = reviewer
|
|
754
|
+
record.reviewed_at = datetime.now()
|
|
755
|
+
record.comments = comments
|
|
756
|
+
self._trigger_callbacks(ApprovalStatus.REJECTED, record)
|
|
757
|
+
return record
|
|
758
|
+
|
|
759
|
+
def on_status_change(
|
|
760
|
+
self, status: ApprovalStatus, callback: Callable[[ApprovalRecord], None]
|
|
761
|
+
) -> None:
|
|
762
|
+
"""Register callback for status change."""
|
|
763
|
+
if status not in self._callbacks:
|
|
764
|
+
self._callbacks[status] = []
|
|
765
|
+
self._callbacks[status].append(callback)
|
|
766
|
+
|
|
767
|
+
def _trigger_callbacks(self, status: ApprovalStatus, record: ApprovalRecord) -> None:
|
|
768
|
+
"""Trigger callbacks for status."""
|
|
769
|
+
for callback in self._callbacks.get(status, []):
|
|
770
|
+
try:
|
|
771
|
+
callback(record)
|
|
772
|
+
except Exception as e:
|
|
773
|
+
logger.warning(f"Approval callback failed: {e}")
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
# =============================================================================
|
|
777
|
+
# =============================================================================
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
@dataclass
|
|
781
|
+
class ComplianceRule:
|
|
782
|
+
"""Compliance checking rule.
|
|
783
|
+
|
|
784
|
+
References:
|
|
785
|
+
REPORT-025: Report Compliance Checking
|
|
786
|
+
"""
|
|
787
|
+
|
|
788
|
+
id: str
|
|
789
|
+
name: str
|
|
790
|
+
description: str
|
|
791
|
+
check: Callable[[dict[str, Any]], bool]
|
|
792
|
+
severity: str = "error" # error, warning, info
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
@dataclass
|
|
796
|
+
class ComplianceResult:
|
|
797
|
+
"""Compliance check result."""
|
|
798
|
+
|
|
799
|
+
passed: bool
|
|
800
|
+
violations: list[tuple[str, str]] = field(default_factory=list)
|
|
801
|
+
warnings: list[tuple[str, str]] = field(default_factory=list)
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
class ComplianceChecker:
|
|
805
|
+
"""Report compliance checker.
|
|
806
|
+
|
|
807
|
+
References:
|
|
808
|
+
REPORT-025: Report Compliance Checking
|
|
809
|
+
"""
|
|
810
|
+
|
|
811
|
+
def __init__(self): # type: ignore[no-untyped-def]
|
|
812
|
+
self._rules: list[ComplianceRule] = []
|
|
813
|
+
|
|
814
|
+
def add_rule(
|
|
815
|
+
self,
|
|
816
|
+
name: str,
|
|
817
|
+
check: Callable[[dict[str, Any]], bool],
|
|
818
|
+
description: str = "",
|
|
819
|
+
severity: str = "error",
|
|
820
|
+
) -> None:
|
|
821
|
+
"""Add compliance rule."""
|
|
822
|
+
rule = ComplianceRule(
|
|
823
|
+
id=str(uuid.uuid4()),
|
|
824
|
+
name=name,
|
|
825
|
+
description=description,
|
|
826
|
+
check=check,
|
|
827
|
+
severity=severity,
|
|
828
|
+
)
|
|
829
|
+
self._rules.append(rule)
|
|
830
|
+
|
|
831
|
+
def check(self, report_data: dict[str, Any]) -> ComplianceResult:
|
|
832
|
+
"""Check report against all rules."""
|
|
833
|
+
violations = []
|
|
834
|
+
warnings = []
|
|
835
|
+
|
|
836
|
+
for rule in self._rules:
|
|
837
|
+
try:
|
|
838
|
+
if not rule.check(report_data):
|
|
839
|
+
if rule.severity == "error":
|
|
840
|
+
violations.append((rule.name, rule.description))
|
|
841
|
+
else:
|
|
842
|
+
warnings.append((rule.name, rule.description))
|
|
843
|
+
except Exception as e:
|
|
844
|
+
logger.warning(f"Compliance rule {rule.name} failed: {e}")
|
|
845
|
+
|
|
846
|
+
return ComplianceResult(
|
|
847
|
+
passed=len(violations) == 0, violations=violations, warnings=warnings
|
|
848
|
+
)
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
# =============================================================================
|
|
852
|
+
# =============================================================================
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
@dataclass
|
|
856
|
+
class LocaleStrings:
|
|
857
|
+
"""Localized strings for a locale.
|
|
858
|
+
|
|
859
|
+
References:
|
|
860
|
+
REPORT-026: Report Localization
|
|
861
|
+
"""
|
|
862
|
+
|
|
863
|
+
locale: str
|
|
864
|
+
strings: dict[str, str] = field(default_factory=dict)
|
|
865
|
+
date_format: str = "%Y-%m-%d"
|
|
866
|
+
time_format: str = "%H:%M:%S"
|
|
867
|
+
number_decimal: str = "."
|
|
868
|
+
number_thousand: str = ","
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
class ReportLocalizer:
|
|
872
|
+
"""Report localization manager.
|
|
873
|
+
|
|
874
|
+
References:
|
|
875
|
+
REPORT-026: Report Localization
|
|
876
|
+
"""
|
|
877
|
+
|
|
878
|
+
def __init__(self, default_locale: str = "en_US"):
|
|
879
|
+
self.default_locale = default_locale
|
|
880
|
+
self._locales: dict[str, LocaleStrings] = {}
|
|
881
|
+
self._register_defaults()
|
|
882
|
+
|
|
883
|
+
def _register_defaults(self) -> None:
|
|
884
|
+
"""Register default locales."""
|
|
885
|
+
self._locales["en_US"] = LocaleStrings(
|
|
886
|
+
locale="en_US",
|
|
887
|
+
strings={
|
|
888
|
+
"title": "Report",
|
|
889
|
+
"summary": "Summary",
|
|
890
|
+
"pass": "PASS",
|
|
891
|
+
"fail": "FAIL",
|
|
892
|
+
},
|
|
893
|
+
)
|
|
894
|
+
self._locales["de_DE"] = LocaleStrings(
|
|
895
|
+
locale="de_DE",
|
|
896
|
+
strings={
|
|
897
|
+
"title": "Bericht",
|
|
898
|
+
"summary": "Zusammenfassung",
|
|
899
|
+
"pass": "BESTANDEN",
|
|
900
|
+
"fail": "DURCHGEFALLEN",
|
|
901
|
+
},
|
|
902
|
+
date_format="%d.%m.%Y",
|
|
903
|
+
number_decimal=",",
|
|
904
|
+
number_thousand=".",
|
|
905
|
+
)
|
|
906
|
+
|
|
907
|
+
def get_string(self, key: str, locale: str | None = None) -> str:
|
|
908
|
+
"""Get localized string."""
|
|
909
|
+
loc = locale or self.default_locale
|
|
910
|
+
strings = self._locales.get(loc, self._locales[self.default_locale])
|
|
911
|
+
return strings.strings.get(key, key)
|
|
912
|
+
|
|
913
|
+
def format_number(self, value: float, locale: str | None = None) -> str:
|
|
914
|
+
"""Format number for locale."""
|
|
915
|
+
loc_strings = self._locales.get(
|
|
916
|
+
locale or self.default_locale, self._locales[self.default_locale]
|
|
917
|
+
)
|
|
918
|
+
formatted = f"{value:,.2f}"
|
|
919
|
+
# Replace separators
|
|
920
|
+
formatted = formatted.replace(",", "TEMP")
|
|
921
|
+
formatted = formatted.replace(".", loc_strings.number_decimal)
|
|
922
|
+
formatted = formatted.replace("TEMP", loc_strings.number_thousand)
|
|
923
|
+
return formatted
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
# =============================================================================
|
|
927
|
+
# =============================================================================
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
@dataclass
|
|
931
|
+
class AccessibilityOptions:
|
|
932
|
+
"""Accessibility options for reports.
|
|
933
|
+
|
|
934
|
+
References:
|
|
935
|
+
REPORT-027: Report Accessibility
|
|
936
|
+
"""
|
|
937
|
+
|
|
938
|
+
alt_text_required: bool = True
|
|
939
|
+
high_contrast: bool = False
|
|
940
|
+
screen_reader_friendly: bool = True
|
|
941
|
+
keyboard_navigable: bool = True
|
|
942
|
+
wcag_level: str = "AA" # A, AA, AAA
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
def add_accessibility_features(html_content: str, options: AccessibilityOptions) -> str:
|
|
946
|
+
"""Add accessibility features to HTML report.
|
|
947
|
+
|
|
948
|
+
Args:
|
|
949
|
+
html_content: HTML content
|
|
950
|
+
options: Accessibility options
|
|
951
|
+
|
|
952
|
+
Returns:
|
|
953
|
+
Enhanced HTML content
|
|
954
|
+
|
|
955
|
+
References:
|
|
956
|
+
REPORT-027: Report Accessibility
|
|
957
|
+
"""
|
|
958
|
+
# Add ARIA landmarks
|
|
959
|
+
html_content = html_content.replace(
|
|
960
|
+
'<div class="report">',
|
|
961
|
+
'<div class="report" role="main" aria-label="Report Content">',
|
|
962
|
+
)
|
|
963
|
+
|
|
964
|
+
# Add skip navigation link
|
|
965
|
+
skip_nav = '<a href="#main-content" class="skip-link">Skip to main content</a>'
|
|
966
|
+
html_content = html_content.replace("<body>", f"<body>{skip_nav}")
|
|
967
|
+
|
|
968
|
+
# Add high contrast styles if enabled
|
|
969
|
+
if options.high_contrast:
|
|
970
|
+
contrast_styles = """
|
|
971
|
+
<style>
|
|
972
|
+
body { background: white !important; color: black !important; }
|
|
973
|
+
a { color: blue !important; }
|
|
974
|
+
.pass { background: green !important; color: white !important; }
|
|
975
|
+
.fail { background: red !important; color: white !important; }
|
|
976
|
+
</style>
|
|
977
|
+
"""
|
|
978
|
+
html_content = html_content.replace("</head>", f"{contrast_styles}</head>")
|
|
979
|
+
|
|
980
|
+
return html_content
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
# =============================================================================
|
|
984
|
+
# =============================================================================
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
class ReportEncryption:
|
|
988
|
+
"""Report encryption utilities.
|
|
989
|
+
|
|
990
|
+
References:
|
|
991
|
+
REPORT-028: Report Encryption
|
|
992
|
+
"""
|
|
993
|
+
|
|
994
|
+
@staticmethod
|
|
995
|
+
def encrypt_content(content: bytes, password: str) -> bytes:
|
|
996
|
+
"""Encrypt report content.
|
|
997
|
+
|
|
998
|
+
Args:
|
|
999
|
+
content: Content bytes to encrypt.
|
|
1000
|
+
password: Encryption password.
|
|
1001
|
+
|
|
1002
|
+
Returns:
|
|
1003
|
+
Encrypted content bytes.
|
|
1004
|
+
|
|
1005
|
+
Note:
|
|
1006
|
+
Uses simple XOR encryption for demonstration.
|
|
1007
|
+
In production, use proper encryption (AES, etc.).
|
|
1008
|
+
"""
|
|
1009
|
+
key = hashlib.sha256(password.encode()).digest()
|
|
1010
|
+
encrypted = bytearray()
|
|
1011
|
+
for i, byte in enumerate(content):
|
|
1012
|
+
encrypted.append(byte ^ key[i % len(key)])
|
|
1013
|
+
return bytes(encrypted)
|
|
1014
|
+
|
|
1015
|
+
@staticmethod
|
|
1016
|
+
def decrypt_content(encrypted: bytes, password: str) -> bytes:
|
|
1017
|
+
"""Decrypt report content."""
|
|
1018
|
+
# XOR is symmetric
|
|
1019
|
+
return ReportEncryption.encrypt_content(encrypted, password)
|
|
1020
|
+
|
|
1021
|
+
@staticmethod
|
|
1022
|
+
def encrypt_file(input_path: Path, output_path: Path, password: str) -> None:
|
|
1023
|
+
"""Encrypt report file."""
|
|
1024
|
+
with open(input_path, "rb") as f:
|
|
1025
|
+
content = f.read()
|
|
1026
|
+
encrypted = ReportEncryption.encrypt_content(content, password)
|
|
1027
|
+
with open(output_path, "wb") as f:
|
|
1028
|
+
f.write(encrypted)
|
|
1029
|
+
|
|
1030
|
+
@staticmethod
|
|
1031
|
+
def decrypt_file(input_path: Path, output_path: Path, password: str) -> None:
|
|
1032
|
+
"""Decrypt report file."""
|
|
1033
|
+
with open(input_path, "rb") as f:
|
|
1034
|
+
encrypted = f.read()
|
|
1035
|
+
decrypted = ReportEncryption.decrypt_content(encrypted, password)
|
|
1036
|
+
with open(output_path, "wb") as f:
|
|
1037
|
+
f.write(decrypted)
|
|
1038
|
+
|
|
1039
|
+
|
|
1040
|
+
# =============================================================================
|
|
1041
|
+
# =============================================================================
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
@dataclass
|
|
1045
|
+
class Watermark:
|
|
1046
|
+
"""Report watermark configuration.
|
|
1047
|
+
|
|
1048
|
+
References:
|
|
1049
|
+
REPORT-029: Report Watermarking
|
|
1050
|
+
"""
|
|
1051
|
+
|
|
1052
|
+
text: str = "CONFIDENTIAL"
|
|
1053
|
+
opacity: float = 0.1
|
|
1054
|
+
rotation: int = -45
|
|
1055
|
+
position: str = "center" # center, header, footer
|
|
1056
|
+
font_size: int = 48
|
|
1057
|
+
|
|
1058
|
+
|
|
1059
|
+
def add_watermark(html_content: str, watermark: Watermark) -> str:
|
|
1060
|
+
"""Add watermark to HTML report.
|
|
1061
|
+
|
|
1062
|
+
Args:
|
|
1063
|
+
html_content: HTML content
|
|
1064
|
+
watermark: Watermark configuration
|
|
1065
|
+
|
|
1066
|
+
Returns:
|
|
1067
|
+
HTML with watermark
|
|
1068
|
+
|
|
1069
|
+
References:
|
|
1070
|
+
REPORT-029: Report Watermarking
|
|
1071
|
+
"""
|
|
1072
|
+
watermark_css = f"""
|
|
1073
|
+
<style>
|
|
1074
|
+
.watermark {{
|
|
1075
|
+
position: fixed;
|
|
1076
|
+
top: 50%;
|
|
1077
|
+
left: 50%;
|
|
1078
|
+
transform: translate(-50%, -50%) rotate({watermark.rotation}deg);
|
|
1079
|
+
font-size: {watermark.font_size}px;
|
|
1080
|
+
color: rgba(128, 128, 128, {watermark.opacity});
|
|
1081
|
+
pointer-events: none;
|
|
1082
|
+
z-index: 1000;
|
|
1083
|
+
white-space: nowrap;
|
|
1084
|
+
}}
|
|
1085
|
+
</style>
|
|
1086
|
+
"""
|
|
1087
|
+
watermark_div = f'<div class="watermark">{watermark.text}</div>'
|
|
1088
|
+
|
|
1089
|
+
html_content = html_content.replace("</head>", f"{watermark_css}</head>")
|
|
1090
|
+
html_content = html_content.replace("<body>", f"<body>{watermark_div}")
|
|
1091
|
+
|
|
1092
|
+
return html_content
|
|
1093
|
+
|
|
1094
|
+
|
|
1095
|
+
# =============================================================================
|
|
1096
|
+
# =============================================================================
|
|
1097
|
+
|
|
1098
|
+
|
|
1099
|
+
@dataclass
|
|
1100
|
+
class AuditEntry:
|
|
1101
|
+
"""Audit trail entry.
|
|
1102
|
+
|
|
1103
|
+
References:
|
|
1104
|
+
REPORT-030: Report Audit Trail
|
|
1105
|
+
"""
|
|
1106
|
+
|
|
1107
|
+
id: str
|
|
1108
|
+
report_id: str
|
|
1109
|
+
action: str
|
|
1110
|
+
user: str
|
|
1111
|
+
timestamp: datetime
|
|
1112
|
+
details: dict[str, Any] = field(default_factory=dict)
|
|
1113
|
+
ip_address: str = ""
|
|
1114
|
+
|
|
1115
|
+
|
|
1116
|
+
class AuditTrail:
|
|
1117
|
+
"""Report audit trail manager.
|
|
1118
|
+
|
|
1119
|
+
References:
|
|
1120
|
+
REPORT-030: Report Audit Trail
|
|
1121
|
+
"""
|
|
1122
|
+
|
|
1123
|
+
def __init__(self, storage_path: Path | None = None):
|
|
1124
|
+
self.storage_path = storage_path
|
|
1125
|
+
self._entries: list[AuditEntry] = []
|
|
1126
|
+
|
|
1127
|
+
def log(
|
|
1128
|
+
self,
|
|
1129
|
+
report_id: str,
|
|
1130
|
+
action: str,
|
|
1131
|
+
user: str,
|
|
1132
|
+
details: dict[str, Any] | None = None,
|
|
1133
|
+
) -> AuditEntry:
|
|
1134
|
+
"""Log audit entry."""
|
|
1135
|
+
entry = AuditEntry(
|
|
1136
|
+
id=str(uuid.uuid4()),
|
|
1137
|
+
report_id=report_id,
|
|
1138
|
+
action=action,
|
|
1139
|
+
user=user,
|
|
1140
|
+
timestamp=datetime.now(),
|
|
1141
|
+
details=details or {},
|
|
1142
|
+
)
|
|
1143
|
+
self._entries.append(entry)
|
|
1144
|
+
|
|
1145
|
+
# Persist if storage configured
|
|
1146
|
+
if self.storage_path:
|
|
1147
|
+
self._persist()
|
|
1148
|
+
|
|
1149
|
+
return entry
|
|
1150
|
+
|
|
1151
|
+
def get_for_report(self, report_id: str) -> list[AuditEntry]:
|
|
1152
|
+
"""Get audit entries for report."""
|
|
1153
|
+
return [e for e in self._entries if e.report_id == report_id]
|
|
1154
|
+
|
|
1155
|
+
def get_by_user(self, user: str) -> list[AuditEntry]:
|
|
1156
|
+
"""Get audit entries by user."""
|
|
1157
|
+
return [e for e in self._entries if e.user == user]
|
|
1158
|
+
|
|
1159
|
+
def export(self, format_: str = "json") -> str:
|
|
1160
|
+
"""Export audit trail."""
|
|
1161
|
+
if format_ == "json":
|
|
1162
|
+
return json.dumps(
|
|
1163
|
+
[
|
|
1164
|
+
{
|
|
1165
|
+
"id": e.id,
|
|
1166
|
+
"report_id": e.report_id,
|
|
1167
|
+
"action": e.action,
|
|
1168
|
+
"user": e.user,
|
|
1169
|
+
"timestamp": e.timestamp.isoformat(),
|
|
1170
|
+
"details": e.details,
|
|
1171
|
+
}
|
|
1172
|
+
for e in self._entries
|
|
1173
|
+
],
|
|
1174
|
+
indent=2,
|
|
1175
|
+
)
|
|
1176
|
+
return ""
|
|
1177
|
+
|
|
1178
|
+
def _persist(self) -> None:
|
|
1179
|
+
"""Persist audit trail to storage."""
|
|
1180
|
+
if self.storage_path:
|
|
1181
|
+
with open(self.storage_path, "w") as f:
|
|
1182
|
+
f.write(self.export("json"))
|
|
1183
|
+
|
|
1184
|
+
|
|
1185
|
+
__all__ = [
|
|
1186
|
+
# Accessibility (REPORT-027)
|
|
1187
|
+
"AccessibilityOptions",
|
|
1188
|
+
# Annotations (REPORT-013)
|
|
1189
|
+
"Annotation",
|
|
1190
|
+
"AnnotationManager",
|
|
1191
|
+
# Approval (REPORT-024)
|
|
1192
|
+
"ApprovalRecord",
|
|
1193
|
+
"ApprovalStatus",
|
|
1194
|
+
"ApprovalWorkflow",
|
|
1195
|
+
# Archiving (REPORT-021)
|
|
1196
|
+
"ArchivedReport",
|
|
1197
|
+
# Audit Trail (REPORT-030)
|
|
1198
|
+
"AuditEntry",
|
|
1199
|
+
"AuditTrail",
|
|
1200
|
+
# Compliance (REPORT-025)
|
|
1201
|
+
"ComplianceChecker",
|
|
1202
|
+
"ComplianceResult",
|
|
1203
|
+
"ComplianceRule",
|
|
1204
|
+
# Templates (REPORT-011)
|
|
1205
|
+
"CustomTemplate",
|
|
1206
|
+
# Distribution (REPORT-020)
|
|
1207
|
+
"DistributionChannel",
|
|
1208
|
+
"DistributionConfig",
|
|
1209
|
+
# Interactive (REPORT-012)
|
|
1210
|
+
"InteractiveElement",
|
|
1211
|
+
"InteractiveElementType",
|
|
1212
|
+
# Localization (REPORT-026)
|
|
1213
|
+
"LocaleStrings",
|
|
1214
|
+
"ReportArchive",
|
|
1215
|
+
"ReportDistributor",
|
|
1216
|
+
# Encryption (REPORT-028)
|
|
1217
|
+
"ReportEncryption",
|
|
1218
|
+
"ReportLocalizer",
|
|
1219
|
+
# Scheduling (REPORT-017)
|
|
1220
|
+
"ReportSchedule",
|
|
1221
|
+
"ReportScheduler",
|
|
1222
|
+
# Search (REPORT-022)
|
|
1223
|
+
"ReportSearchIndex",
|
|
1224
|
+
# Versioning (REPORT-023)
|
|
1225
|
+
"ReportVersion",
|
|
1226
|
+
"ReportVersionControl",
|
|
1227
|
+
"ScheduleFrequency",
|
|
1228
|
+
"SearchResult",
|
|
1229
|
+
"TemplateField",
|
|
1230
|
+
# Watermarking (REPORT-029)
|
|
1231
|
+
"Watermark",
|
|
1232
|
+
"add_accessibility_features",
|
|
1233
|
+
"add_watermark",
|
|
1234
|
+
]
|