Skip to content
Snippets Groups Projects
Commit e08cf546 authored by Michel Spils's avatar Michel Spils
Browse files

dashboard

parent 56a69d31
Branches
No related tags found
No related merge requests found
from dash import html
def create_collapsible_section(title, content, is_open=True):
return html.Div([
html.Div([
html.H3(title, style={'display': 'inline-block', 'marginRight': '10px'}),
html.Button(
'' if is_open else '',
id={'type': 'collapse-button', 'section': title},
style={
'border': 'none',
'background': 'none',
'fontSize': '20px',
'cursor': 'pointer'
}
)
], style={'marginBottom': '10px'}),
html.Div(
content,
id={'type': 'collapse-content', 'section': title},
style={'display': 'block' if is_open else 'none'}
)
])
\ No newline at end of file
import plotly.graph_objects as go
from dash import html, dash_table
import plotly.graph_objects as go
from plotly.subplots import make_subplots
import datetime
from datetime import timedelta, datetime
import plotly.express as px
import pandas as pd
def create_log_table(logs_data):
"""
Creates a configured DataTable for logging display
Args:
logs_data: List of dictionaries containing log data
Returns:
dash_table.DataTable: Configured table for log display
"""
columns = [
{'name': 'Time', 'id': 'timestamp'},
{'name': 'Level', 'id': 'level'},
{'name': 'Message', 'id': 'message'},
{'name': 'Exception', 'id': 'exception'}
]
style_data_conditional = [
{
'if': {'filter_query': '{level} = "ERROR"'},
'backgroundColor': '#ffebee',
'color': '#c62828'
},
{
'if': {'filter_query': '{level} = "WARNING"'},
'backgroundColor': '#fff3e0',
'color': '#ef6c00'
}
]
style_table = {
'overflowX': 'auto'
}
style_cell = {
'textAlign': 'left',
'padding': '8px',
'whiteSpace': 'normal',
'height': 'auto',
'fontSize': '12px',
}
return dash_table.DataTable(
columns=columns,
data=logs_data,
style_data_conditional=style_data_conditional,
style_table=style_table,
style_cell=style_cell
)
def create_historical_plot(df_historical, model_name):
"""
Creates a plotly figure for historical sensor data
Args:
df_historical: DataFrame with sensor measurements
model_name: String name of the model
Returns:
plotly.graph_objects.Figure: Configured plot
"""
fig = go.Figure()
# Add a trace for each sensor
for column in df_historical.columns:
fig.add_trace(
go.Scatter(
x=df_historical.index,
y=df_historical[column],
name=column,
mode='lines',
hovertemplate='%{y:.2f}<extra>%{x}</extra>'
)
)
# Update layout
fig.update_layout(
title=f'Sensor Measurements - Last 144 Hours for {model_name}',
xaxis_title='Time',
yaxis_title='Value',
height=600,
showlegend=True,
legend=dict(
yanchor="top",
y=0.99,
xanchor="left",
x=1.05
),
margin=dict(r=150)
)
return fig
def create_historical_table(df_historical):
"""
Creates a formatted DataTable for historical sensor data
Args:
df_historical: DataFrame with sensor measurements
Returns:
dash.html.Div: Div containing configured DataTable
"""
df_table = df_historical.reset_index()
df_table['tstamp'] = df_table['tstamp'].dt.strftime('%Y-%m-%d %H:%M')
# Table styles
style_table = {
'maxHeight': '1200px',
'maxWidth': '1600px',
'overflowY': 'auto',
'width': '100%'
}
style_cell = {
'textAlign': 'right',
'padding': '5px',
'minWidth': '100px',
'whiteSpace': 'normal',
'fontSize': '12px',
}
style_header = {
'backgroundColor': '#f4f4f4',
'fontWeight': 'bold',
'textAlign': 'center',
}
# Create conditional styles for blank values and timestamp
style_data_conditional = [
{
'if': {
'filter_query': '{{{}}} is blank'.format(col),
'column_id': col
},
'backgroundColor': '#ffebee',
'color': '#c62828'
} for col in df_table.columns if col != 'tstamp'
] + [
{
'if': {'column_id': 'tstamp'},
'textAlign': 'left',
'minWidth': '150px'
}
]
# Create table columns configuration
columns = [{'name': col, 'id': col} for col in df_table.columns]
return html.Div(
dash_table.DataTable(
id='historical-table',
columns=columns,
data=df_table.to_dict('records'),
style_table=style_table,
style_cell=style_cell,
style_header=style_header,
style_data_conditional=style_data_conditional,
fixed_columns={'headers': True, 'data': 1},
sort_action='native',
sort_mode='single',
)
)
def create_inp_forecast_status_table(df_forecast):
"""
Creates a status table showing availability of forecasts for each sensor at 3-hour intervals
Args:
df_forecast: DataFrame with columns tstamp, sensor_name containing forecast data
Returns:
dash.html.Div: Div containing configured DataTable
"""
# Get unique sensor names
sensor_names = sorted(df_forecast['sensor_name'].unique())
# Create index of last 48 hours at 3-hour intervals
last_required_hour = datetime.now().replace(minute=0, second=0, microsecond=0)
while last_required_hour.hour % 3 != 0:
last_required_hour -= timedelta(hours=1)
time_range = pd.date_range(
end=last_required_hour,
periods=17, # 48 hours / 3 + 1
freq='3h'
)
# Initialize result DataFrame with NaN
status_df = pd.DataFrame(index=time_range, columns=sensor_names)
# For each sensor and timestamp, check if data exists
for sensor in sensor_names:
sensor_data = df_forecast[df_forecast['sensor_name'] == sensor]
for timestamp in time_range:
#TODO genauer hier
has_data = any(
(sensor_data['tstamp'] <= timestamp) &
(sensor_data['tstamp'] + timedelta(hours=48) >= timestamp)
)
status_df.loc[timestamp, sensor] = 'OK' if has_data else 'Missing'
# Reset index to make timestamp a column
status_df = status_df.reset_index()
status_df['index'] = status_df['index'].dt.strftime('%Y-%m-%d %H:%M')
# Configure table styles
style_data_conditional = [
{
'if': {
'filter_query': '{{{col}}} = "Missing"'.format(col=col),
'column_id': col
},
'backgroundColor': '#ffebee',
'color': '#c62828'
} for col in sensor_names
] + [
{
'if': {
'filter_query': '{{{col}}} = "OK"'.format(col=col),
'column_id': col
},
'backgroundColor': '#e8f5e9',
'color': '#2e7d32'
} for col in sensor_names
]
return html.Div(
dash_table.DataTable(
id='forecast-status-table',
columns=[
{'name': 'Timestamp', 'id': 'index'},
*[{'name': col, 'id': col} for col in sensor_names]
],
data=status_df.to_dict('records'),
style_table={
'overflowX': 'auto',
'width': '100%'
},
style_cell={
'textAlign': 'center',
'padding': '5px',
'minWidth': '100px',
'fontSize': '12px',
},
style_header={
'backgroundColor': '#f4f4f4',
'fontWeight': 'bold',
'textAlign': 'center',
},
style_data_conditional=style_data_conditional,
fixed_columns={'headers': True, 'data': 1},
sort_action='native',
sort_mode='single',
)
)
def create_input_forecasts_plot(df_forecast, df_historical):
"""
Creates a figure with subplots for each sensor, showing forecast lines and historical data
Args:
df_forecast: DataFrame with columns tstamp, sensor_name, member, h1-h48
df_historical: DataFrame with sensor_name columns and timestamp index
Returns:
plotly.graph_objects.Figure: Figure with subplots
"""
# Get unique sensors and members
sensors = df_forecast['sensor_name'].unique()
# Create a color sequence for different forecast start times
colors = px.colors.qualitative.Set3
# Create subplot figure
fig = make_subplots(
rows=len(sensors),
cols=1,
subplot_titles=[f'Sensor: {sensor}' for sensor in sensors],
vertical_spacing=0.1
)
# For each sensor
for sensor_idx, sensor in enumerate(sensors, 1):
# Add historical data
if sensor in df_historical.columns:
fig.add_trace(
go.Scatter(
x=df_historical.index,
y=df_historical[sensor],
name=sensor,
legendgroup=sensor,
showlegend=True, # Show in legend for all sensors
line=dict(color='black', width=2),
hovertemplate='Time: %{x}<br>Value: %{y:.2f}<br>Historical<extra></extra>'
),
row=sensor_idx,
col=1
)
sensor_data = df_forecast[df_forecast['sensor_name'] == sensor]
members = sensor_data['member'].unique()
# Get unique forecast start times for color assignment
start_times = sorted(sensor_data['tstamp'].unique())
start_time_colors = {t: colors[i % len(colors)] for i, t in enumerate(start_times)}
# For each member
for member in members:
member_data = sensor_data[sensor_data['member'] == member]
# For each forecast (row)
for _, row in member_data.iterrows():
start_time = row['tstamp']
legend_group = f'{sensor} {start_time.strftime("%Y%m%d%H%M")}'
legendgrouptitle_text = f'{sensor}: {start_time.strftime("%Y-%m-%d %H:%M")}'
# Create x values (timestamps) for this forecast
timestamps = [start_time + timedelta(hours=i) for i in range(1, 49)]
# Get y values (h1 to h48)
values = [row[f'h{i}'] for i in range(1, 49)]
# Add trace to the subplot
fig.add_trace(
go.Scatter(
x=timestamps,
y=values,
name=f'{start_time.strftime("%Y-%m-%d %H:%M")}',
#name=f'M{member} {start_time.strftime("%Y-%m-%d %H:%M")}',
legendgroup=legend_group,
#legendgrouptitle_text=legendgrouptitle_text,
#showlegend=True, # Show all traces in legend
showlegend=(member == members[0]).item(),# and sensor_idx == 1, # Show all traces in legend
line=dict(
color=start_time_colors[start_time],
width=1,
dash='solid' if member == members[0] else 'dot'
),
hovertemplate=(
'Time: %{x}<br>'
'Value: %{y:.2f}<br>'
f'Member: {member}<br>'
f'Start: {start_time}<extra></extra>'
)
),
row=sensor_idx,
col=1
)
# Update layout
fig.update_layout(
height=400 * len(sensors), # Adjust height based on number of sensors
title='Forecast Values by Sensor with Historical Data',
showlegend=True,
legend=dict(
yanchor="top",
y=0.99,
xanchor="left",
x=1.05,
groupclick="togglegroup", # Allows clicking group title to toggle all traces
#groupclick="toggleitem", # Allows clicking group title to toggle all traces
itemsizing='constant', # Makes legend items constant size
tracegroupgap=5 # Add small gap between groups
),
margin=dict(r=150)
)
# Update all x and y axes labels
for i in range(len(sensors)):
fig.update_xaxes(title_text="Time", row=i+1, col=1)
fig.update_yaxes(title_text="Value", row=i+1, col=1)
return fig
\ No newline at end of file
from dash import html, dcc, dash_table, Dash
from dash.dependencies import Input, Output, State, MATCH
import plotly.express as px
import plotly.graph_objs as go
import pandas as pd
from datetime import datetime, timedelta
from sqlalchemy import create_engine, select, and_, desc
from sqlalchemy.orm import Session
from utils.orm_classes import Base, InputForecasts, Modell, PegelForecasts, Sensor, Log, ModellSensor, SensorData
import oracledb
import os
from sqlalchemy import select, func
from dash_tools.style_configs import create_log_table, create_historical_plot, create_historical_table,create_input_forecasts_plot,create_inp_forecast_status_table
from dash_tools.layout_helper import create_collapsible_section
NUM_RECENT_INPUT_FORECASTS = 3
class ForecastMonitor:
def __init__(self, username, password, dsn):
self.db_params = {
"user": username,
"password": password,
"dsn": dsn
}
self.con = oracledb.connect(**self.db_params)
self.engine = create_engine("oracle+oracledb://", creator=lambda: self.con)
self.app = Dash(__name__)
self.setup_layout()
self.setup_callbacks()
def get_model_name_from_path(self, modelldatei):
if not modelldatei:
return None
return os.path.basename(modelldatei)
def get_active_models_status(self):
try:
now = datetime.now()
last_required_hour = now.replace(minute=0, second=0, microsecond=0)
while last_required_hour.hour % 3 != 0:
last_required_hour -= timedelta(hours=1)
with Session(self.engine) as session:
active_models = session.query(Modell).filter(Modell.aktiv == 1).all()
model_status = []
for model in active_models:
actual_model_name = self.get_model_name_from_path(model.modelldatei)
if not actual_model_name:
continue
current_forecast = session.query(PegelForecasts).filter(
and_(
PegelForecasts.model == actual_model_name,
PegelForecasts.tstamp == last_required_hour
)
).first()
last_valid_forecast = session.query(PegelForecasts).filter(
and_(
PegelForecasts.model == actual_model_name,
PegelForecasts.h1 != None
)
).order_by(PegelForecasts.tstamp.desc()).first()
model_status.append({
'model_name': model.modellname,
'actual_model_name': actual_model_name,
'model_id': model.id, # Added for historical data lookup
'sensor_name': last_valid_forecast.sensor_name if last_valid_forecast else None,
'has_current_forecast': current_forecast is not None,
'last_forecast_time': last_valid_forecast.tstamp if last_valid_forecast else None,
'forecast_created': last_valid_forecast.created if last_valid_forecast else None,
})
return {
'model_status': model_status,
'last_check_time': now,
'required_timestamp': last_required_hour
}
except Exception as e:
print(f"Error getting model status: {str(e)}")
return None
def get_input_forecasts(self, sensor_names):
"""Get 3 most recent input forecasts for the given sensor names"""
try:
with Session(self.engine) as session:
# Subquery to rank rows by timestamp for each sensor/member combination
subq = (
select(
InputForecasts,
func.row_number()
.over(
partition_by=[InputForecasts.sensor_name, InputForecasts.member],
order_by=InputForecasts.tstamp.desc()
).label('rn')
)
.where(InputForecasts.sensor_name.in_(sensor_names))
.subquery()
)
# Main query to get only the top 3 rows
stmt = (
select(subq)
.where(subq.c.rn <= NUM_RECENT_INPUT_FORECASTS)
.order_by(
subq.c.sensor_name,
subq.c.member,
subq.c.tstamp.desc()
)
)
df = pd.read_sql(sql=stmt, con=self.engine)
df.drop(columns=['rn'], inplace=True)
return df
except Exception as e:
raise RuntimeError(f"Error getting input forecasts: {str(e)}")
def get_recent_logs(self, sensor_name):
if sensor_name is None:
return []
try:
with Session(self.engine) as session:
logs = session.query(Log).filter(
Log.gauge == sensor_name
).order_by(
desc(Log.created)
).limit(10).all()
return [
{
'timestamp': log.created.strftime('%Y-%m-%d %H:%M:%S'),
'level': log.loglevelname,
'sensor': log.gauge,
'message': log.message,
'module': log.module,
'function': log.funcname,
'line': log.lineno,
'exception': log.exception or ''
}
for log in logs
]
except Exception as e:
print(f"Error getting logs: {str(e)}")
return []
def get_historical_data(self, model_id): #TODO external forecast
"""Get last 144 hours of sensor data for all sensors associated with the model"""
try:
with Session(self.engine) as session:
# Get all sensors for this model
model_sensors = session.query(ModellSensor).filter(
ModellSensor.modell_id == model_id
).all()
sensor_names = [ms.sensor_name for ms in model_sensors]
# Get last 144 hours of sensor data
time_threshold = datetime.now() - timedelta(hours=144)
time_threshold= pd.to_datetime("2024-09-13 14:00:00.000") - timedelta(hours=144) #TODO rausnehmen
stmt = select(SensorData).where(
SensorData.tstamp >= time_threshold,
SensorData.sensor_name.in_(sensor_names))
df = pd.read_sql(sql=stmt,con=self.engine,index_col="tstamp")
df = df.pivot(columns="sensor_name", values="sensor_value")[sensor_names]
return df
except Exception as e:
print(f"Error getting historical data: {str(e)}")
return [], []
def setup_layout(self):
self.app.layout = html.Div([
# Add CSS for resizable columns in the app's assets folder instead of inline
html.Div([
html.H1("Forecast Monitoring Dashboard", style={'padding': '20px'}),
# Main container with resizable columns
html.Div([
# Left column
html.Div([
html.Div([
html.H3("Active Models Status"),
html.Div(id='model-status-table'),
dcc.Interval(
id='status-update',
interval=600000, # Update every 10 minutes
)
]),
html.Div([
html.H3("Status Summary"),
dcc.Graph(id='status-summary-chart')
]),
], id='left-column', className='column', style={
'width': '40%',
'minWidth': '200px',
'height': 'calc(100vh - 100px)',
'overflow': 'auto'
}),
# Resizer
html.Div(id='resizer', style={
'cursor': 'col-resize',
'width': '10px',
'backgroundColor': '#f0f0f0',
'transition': 'background-color 0.3s',
':hover': {
'backgroundColor': '#ccc'
}
}),
# Right column
# Right column
html.Div([
dcc.Store(id='current-sensor-names'), # Store current sensor names
create_collapsible_section(
"Recent Logs",
html.Div(id='log-view'),
is_open=True
),
create_collapsible_section(
"Input Forecasts",
html.Div(id='inp-fcst-view'),
is_open=True
),
create_collapsible_section(
"Historical Data",
html.Div(id='historical-view'),
is_open=True
),
], id='right-column', className='column', style={
'width': '60%',
'minWidth': '400px',
'height': 'calc(100vh - 100px)',
'overflow': 'auto'
}),
], style={
'display': 'flex',
'flexDirection': 'row',
'width': '100%',
'height': 'calc(100vh - 100px)'
}),
# Add JavaScript for column resizing using dcc.Store to trigger clientside callback
dcc.Store(id='column-widths', data={'left': 40, 'right': 60}),
]),
])
# Add clientside callback for resizing
self.app.clientside_callback(
"""
function(trigger) {
if (!window.resizeInitialized) {
const resizer = document.getElementById('resizer');
const leftColumn = document.getElementById('left-column');
const rightColumn = document.getElementById('right-column');
let x = 0;
let leftWidth = 0;
const mouseDownHandler = function(e) {
x = e.clientX;
leftWidth = leftColumn.getBoundingClientRect().width;
document.addEventListener('mousemove', mouseMoveHandler);
document.addEventListener('mouseup', mouseUpHandler);
};
const mouseMoveHandler = function(e) {
const dx = e.clientX - x;
const newLeftWidth = ((leftWidth + dx) / resizer.parentNode.getBoundingClientRect().width) * 100;
if (newLeftWidth > 20 && newLeftWidth < 80) {
leftColumn.style.width = `${newLeftWidth}%`;
rightColumn.style.width = `${100 - newLeftWidth}%`;
}
};
const mouseUpHandler = function() {
document.removeEventListener('mousemove', mouseMoveHandler);
document.removeEventListener('mouseup', mouseUpHandler);
};
resizer.addEventListener('mousedown', mouseDownHandler);
window.resizeInitialized = true;
}
return window.dash_clientside.no_update;
}
""",
Output('column-widths', 'data'),
Input('column-widths', 'data'),
)
def setup_callbacks(self):
@self.app.callback(
[Output('model-status-table', 'children'),
Output('status-summary-chart', 'figure')],
Input('status-update', 'n_intervals')
)
def update_dashboard(n):
status = self.get_active_models_status()
if not status:
return html.Div("Error fetching data"), go.Figure()
header = html.Div([
html.H4(f"Status as of {status['last_check_time'].strftime('%Y-%m-%d %H:%M:%S')}"),
html.P(f"Checking forecasts for timestamp: {status['required_timestamp'].strftime('%Y-%m-%d %H:%M:00')}")
])
table = dash_table.DataTable(
id='status-table',
columns=[
{'name': 'Model', 'id': 'model_name'},
{'name': 'Status', 'id': 'has_current_forecast'},
{'name': 'Last Valid', 'id': 'last_forecast_time'},
{'name': 'Created', 'id': 'forecast_created'},
{'name': 'Target Sensor', 'id': 'sensor_name'},
{'name': 'model_id', 'id': 'model_id', 'hideable': True}
],
data=[{
'model_name': row['model_name'],
'has_current_forecast': '' if row['has_current_forecast'] else '',
'last_forecast_time': row['last_forecast_time'].strftime('%Y-%m-%d %H:%M:%S') if row['last_forecast_time'] else 'No valid forecast',
'forecast_created': row['forecast_created'].strftime('%Y-%m-%d %H:%M:%S') if row['forecast_created'] else 'N/A',
'sensor_name': row['sensor_name'],
'model_id': row['model_id']
} for row in status['model_status']],
style_data_conditional=[
{
'if': {'filter_query': '{has_current_forecast} = ""', "column_id": "has_current_forecast"},
'color': 'green'
},
{
'if': {'filter_query': '{has_current_forecast} = ""', "column_id": "has_current_forecast"},
'color': 'red'
}
],
style_table={'overflowX': 'auto'},
style_cell={
'textAlign': 'left',
'padding': '8px',
'whiteSpace': 'normal',
'height': 'auto',
'fontSize': '14px',
},
style_header={
'backgroundColor': '#f4f4f4',
'fontWeight': 'bold'
},
row_selectable='single',
selected_rows=[],
)
df_status = pd.DataFrame(status['model_status'])
fig = px.bar(
df_status.groupby('model_name')['has_current_forecast'].apply(lambda x: (x.sum()/len(x))*100).reset_index(),
x='model_name',
y='has_current_forecast',
title='Forecast Completion Rate by Model (%)',
labels={'has_current_forecast': 'Completion Rate (%)', 'model_name': 'Model Name'}
)
fig.update_layout(yaxis_range=[0, 100])
return html.Div([header, table]), fig
@self.app.callback(
[Output('log-view', 'children'),
Output('historical-view', 'children'),
Output('inp-fcst-view', 'children'),
Output('current-sensor-names', 'data')], # Removed input-forecasts-view
[Input('status-table', 'selected_rows')],
[State('status-table', 'data')]
)
def update_right_column(selected_rows, table_data):
if not selected_rows:
return (html.Div("Select a model to view logs"),
html.Div("Select a model to view Input Forecasts"),
html.Div("Select a model to view historical data"),
None) # Removed input forecasts return
selected_row = table_data[selected_rows[0]]
sensor_name = selected_row['sensor_name']
model_id = selected_row['model_id']
model_name = selected_row['model_name']
# Get logs
logs = self.get_recent_logs(sensor_name)
log_table = create_log_table(logs)
log_view = html.Div([
html.H4(f"Recent Logs for {model_name}"),
log_table
])
# Get historical data
df_historical = self.get_historical_data(model_id)
sensor_names = list(df_historical.columns)
if not df_historical.empty:
fig = create_historical_plot(df_historical, model_name)
historical_table = create_historical_table(df_historical)
historical_view = html.Div([
html.H4(f"Historical Data for {model_name}"),
dcc.Graph(figure=fig),
html.H4("Sensor Data Table", style={'marginTop': '20px', 'marginBottom': '10px'}),
html.Div(historical_table, style={'width': '100%', 'padding': '10px'})
])
else:
historical_view = html.Div("No historical data available")
# Get Input Forecasts
df_inp_fcst = self.get_input_forecasts(sensor_names)
if not df_inp_fcst.empty:
fig_fcst = create_input_forecasts_plot(df_inp_fcst,df_historical)
inp_fcst_table = create_inp_forecast_status_table(df_inp_fcst)
inp_fcst_view = html.Div([
html.H4(f"Input Forecasts for {model_name}"),
dcc.Graph(figure=fig_fcst),
html.H4("Input Forecast Status", style={'marginTop': '20px', 'marginBottom': '10px'}),
html.Div(inp_fcst_table, style={'width': '100%', 'padding': '10px'})
])
else:
inp_fcst_view = html.Div("No input forecasts available")
return (log_view,
historical_view,
inp_fcst_view,
sensor_names) # Removed input forecasts return
@self.app.callback(
Output({'type': 'collapse-content', 'section': MATCH}, 'style'),
Output({'type': 'collapse-button', 'section': MATCH}, 'children'),
Input({'type': 'collapse-button', 'section': MATCH}, 'n_clicks'),
State({'type': 'collapse-content', 'section': MATCH}, 'style'),
prevent_initial_call=True
)
def toggle_collapse(n_clicks, current_style):
if current_style is None:
current_style = {}
if current_style.get('display') == 'none':
return {'display': 'block'}, ''
else:
return {'display': 'none'}, ''
def run(self, host='0.0.0.0', port=8050, debug=True):
self.app.run_server(host=host, port=port, debug=debug)
if __name__ == '__main__':
monitor = ForecastMonitor(
username="c##mspils",
password="cobalt_deviancy",
dsn="localhost/XE"
)
monitor.run()
\ No newline at end of file
......@@ -5,7 +5,6 @@ A collection of classes and functions for interacting with the oracle based Wavo
import logging
from datetime import datetime
from pathlib import Path
from re import I
from typing import List
import warnings
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment