프로젝트 명(폴더) : demo_board

# 파일 경로 설명 구분
1 pcconfig.py    
2 demo_board > demo_board.py    
3 demo_board > demo_state.py    
4 demo_board > demo_auth.py    
5 demo_board > demo_servers.py 다중 Select 컴포넌트 추가 및 Dummy 데이터 수정
6 demo_board > demo_helpers.py    

 

서버 모니터링 demo_servers.py 에서 선택되는 Select 컴포넌트의 값에 따라서 여러 종류의 서버 유형의 결과를 조회하기위해서

Table 상단에 다중 Select 를 추가합니다.

 

수정전

 

수정후

 

Select 항목 선택후 "검색" 결과

수정된 내용

 

SeverState에서 사용할 함수들..

# Select 컴포넌트 2개에 표시될 데이터 List[Dict] Dummy 를 Dataframe으로 리턴
def get_select_data():
    result_list =  [
        {'service_id': 'app_mng', 'parent_id': 'servers', 'service_nm': '인프라 서버 관리'}, 
        {'service_id': 'app_web', 'parent_id': 'app_mng', 'service_nm': 'WEB 서버 관리'},
        {'service_id': 'app_was', 'parent_id': 'app_mng', 'service_nm': 'WAS 서버 관리'},
        {'service_id': 'app_rds', 'parent_id': 'app_mng', 'service_nm': 'DB 서버 관리'}
    ]
    result_list = pd.DataFrame(result_list)
    return result_list

# Dummy 를 Dataframe에서 상위 즉 parent_id가 동일한 항목들을 List로 리턴
def get_select_options(select_list, parent_id):
    return select_list[select_list['parent_id']==parent_id]['service_nm'].values.tolist()

# Dummy 를 Dataframe에서 선택된 service_name 으로 해당 Dict의 service_id를 찾아서 리턴
def get_select_id(select_list, select1_id, option):
    selected_parent = select_list[select_list['parent_id']==select1_id]
    select_selected=selected_parent[selected_parent['service_nm']==option]

    select_id=""
    if len(select_selected) > 0:
        select_id=select_selected['service_id'].values[0] 

    return select_id

 

class ServerState(State):

class ServerState(State):
    """The Substate of base state for the server page."""
    print("──────────────────── [ ServerState(State) ] ────────────────────")
    app_name: str = "Servers" #1 Navigation Bar에 표시될 Page 명

    # (추가됨)
    # select 컴포넌트에서 사용할 Dummy 데이타
    select_list = get_select_data() 

    # 화면 로드시에 첫번째 Select 컴포넌트에 값을 세팅
    select1_options: list[str] = get_select_options(select_list, "servers")
    # 두번째 Select는 빈값으로 두고 첫번째 Select가 선택될때 이벤트를 받아서 항목을 추가
    select2_options: list[str] = []

    # 현재 선택된 Select 항목의 값들을 저장하기위한 vars
    select1_id: str = "" 
    select2_id: str = "" 
    
    select1_value: str = "" 
    select2_value: str = "" 
    
    ...(생략)

    # (추가됨) Select 컴포넌트에서 on_change 이벤트가 발생할때 호출
    def select_change(self, level, value):
    
    	# 선택항목이 select1 이면, 
        if(level == "select1"):
            self.select1_id="" 
            self.select1_value=""
            self.select2_id=""
            self.select2_value="" 
            self.select2_options=[]
            
            # 선택된 Option service_nm 값으로 해당 select_id를 찾아온다.
            select_id = get_select_id(self.select_list, "servers", value)
            
            self.select1_id=select_id
            self.select1_value=value
            # 앞서 찾은 select1의 id 값으로 select2 컴포넌트를위한 옵션 List를 조회/등록
            self.select2_options=get_select_options(self.select_list, select_id)

        elif(level == "select2"):
        	# Select2 가 선택됐을때는 선택된 service_nm에 대한 service_id 를 찾아 var에 저장
            select_id = get_select_id(self.select_list, self.select1_id, value)
            self.select2_id=select_id
            self.select2_value=value
        else:
            return

    # (추가됨) select_change에서 선택된 값들을 해서 "검색" 버튼 클릭시 데이터를 조회
    def get_data_list(self):
        pd_result_list: List[Dict] = get_data(self.select2_id)
        self.table_columns = list(pd_result_list.columns)
        self.table_data = pd_result_list.values.tolist()

 

다중 Select 컴포넌트 그리기

def search_select(ServerState):
    """The Multi Select combo."""
    return pc.box(
        pc.hstack(
            pc.hstack(
                pc.select(
                    ServerState.select1_options,
                    placeholder="Select an Option.",
                    on_change=lambda value: ServerState.select_change('select1', value),
                    #is_disabled=True,
                    border_style="solid",
                    border_width="1px",
                    border_color="#43464B",
                    value=ServerState.select1_value,
                ),
                pc.select(
                    ServerState.select2_options,
                    placeholder="Select an Option.",
                    on_change=lambda value: ServerState.select_change('select2', value),
                    #is_disabled=True,
                    border_style="solid",
                    border_width="1px",
                    border_color="#43464B",
                    value=ServerState.select2_value,
                ),
                pc.button(
                    "검색", #Search
                    bg="navy", #gray
                    color="white", 
                    size="md",
                    width="150px",
                    on_click=ServerState.get_data_list,
                ),
                border_width="0px",
            ),
            border_width="0px",
        ),
        #position="fixed",
        #width="40%",
        #top="105px",
        z_index="500",
    )

 

마지막으로 "/server" 함수에 search_select 추가

def servers():
    return pc.box(
        pc.vstack(
            navbar(State, ServerState.app_name), #2 맨위 상단에 navbar를 추가 
            pc.cond(
                State.logged_in,
                pc.box(
                        #pc.heading("서버 모니터링", font_size="2em"),
                        #pc.heading("서버 모니터링"),
                        search_select(ServerState), # search_select 추가
                        render_table(ServerState),
                        #pc.divider(),
                        #render_datatable(ServerState),
                    ),
                    width="100%",
                    border_width="0px",
                ),
                pc.link(
                    pc.button("먼저 로그인 하세요"),
                    href="/login",
                    color="rgb(107,99,246)",
                    button=True,
                )
            ),
            padding_top="5.5em",
            width="100%",
        ),
    )

 

전체소스

import pynecone as pc

import pandas as pd
from typing import List, Dict

from .demo_state import State # Substate of Base State
from .demo_helpers import navbar

def get_select_data():
    result_list =  [
        {'service_id': 'app_mng', 'parent_id': 'servers', 'service_nm': '인프라 서버 관리'}, 
        {'service_id': 'app_web', 'parent_id': 'app_mng', 'service_nm': 'WEB 서버 관리'},
        {'service_id': 'app_was', 'parent_id': 'app_mng', 'service_nm': 'WAS 서버 관리'},
        {'service_id': 'app_rds', 'parent_id': 'app_mng', 'service_nm': 'DB 서버 관리'}
    ]
    result_list = pd.DataFrame(result_list)
    return result_list

def get_select_options(select_list, parent_id):
    return select_list[select_list['parent_id']==parent_id]['service_nm'].values.tolist()

def get_select_id(select_list, select1_id, option):
    selected_parent = select_list[select_list['parent_id']==select1_id]
    select_selected=selected_parent[selected_parent['service_nm']==option]

    select_id=""
    if len(select_selected) > 0:
        select_id=select_selected['service_id'].values[0] 

    return select_id

def get_data(server_type):
    # 샘플 데이터 List[Dict]
    if server_type == "" or server_type == None:
        result_list = [{'Result': 'No data found (검색 조건을 선택한 후 조회 버튼을 클릭하세요)'}]
    else:
        result_list = [
            {"Server Type": server_type, "Server Name": "alpha", "Total Disk": "10G", "Used Disk": "5G", "Available Disk": "5G", "HTTPS Status": "running", "Last Access": "17/Feb/2023:17:13:51"},
            {"Server Type": server_type, "Server Name": "brovo", "Total Disk": "10G", "Used Disk": "5G", "Available Disk": "5G", "HTTPS Status": "running", "Last Access": "17/Feb/2023:17:13:51"},
            {"Server Type": server_type, "Server Name": "charlie", "Total Disk": "10G", "Used Disk": "5G", "Available Disk": "5G", "HTTPS Status": "running", "Last Access": "17/Feb/2023:17:13:51"},
            {"Server Type": server_type, "Server Name": "delta", "Total Disk": "10G", "Used Disk": "5G", "Available Disk": "5G", "HTTPS Status": "running", "Last Access": "17/Feb/2023:17:13:51"},
            {"Server Type": server_type, "Server Name": "echo", "Total Disk": "10G", "Used Disk": "5G", "Available Disk": "5G", "HTTPS Status": "running", "Last Access": "17/Feb/2023:17:13:51"},
        ]

    pd_result_list = pd.DataFrame(result_list)
    pd_result_list=pd_result_list.fillna('')   # NaN값을 0 or ''로 치환한다.
    pd_result_list=pd_result_list.astype(str)  # 전체 Field를 str로 변환한다.

    return pd_result_list


class ServerState(State):
    """The Substate of base state for the server page."""
    print("──────────────────── [ ServerState(State) ] ────────────────────")
    app_name: str = "Servers" #1 Navigation Bar에 표시될 Page 명

    select_list = get_select_data()

    select1_options: list[str] = get_select_options(select_list, "servers")
    select2_options: list[str] = []

    select1_id: str = "" 
    select2_id: str = "" 
    
    select1_value: str = "" 
    select2_value: str = "" 


    pd_result_list: List[Dict] = get_data("")
    #print(pd_result_list)

    # table_columns : Dict에서 Columns 정보를 가져와서 Table의 thead를 구성
    # table_data : Dataframe에서 value만 List로 만들어서 Table의 tbody를 구성
    # table_name : Table의 table_caption 

    table_columns = list(pd_result_list.columns)
    table_data: List[List[str]] = pd_result_list.values.tolist()
    table_name: str = "WEB 서버 상태 확인"

    box_align: str = ""

    def select_change(self, level, value):

        if(level == "select1"):
            self.select1_id="" 
            self.select1_value=""
            self.select2_id=""
            self.select2_value="" 
            self.select2_options=[]
            
            select_id = get_select_id(self.select_list, "servers", value)
            
            self.select1_id=select_id
            self.select1_value=value
            self.select2_options=get_select_options(self.select_list, select_id)

        elif(level == "select2"):
            select_id = get_select_id(self.select_list, self.select1_id, value)
            self.select2_id=select_id
            self.select2_value=value
        else:
            return

    def get_data_list(self):
        pd_result_list: List[Dict] = get_data(self.select2_id)
        self.table_columns = list(pd_result_list.columns)
        self.table_data = pd_result_list.values.tolist()


def servers():
    return pc.box(
        pc.vstack(
            navbar(State, ServerState.app_name), #2 맨위 상단에 navbar를 추가 
            pc.cond(
                State.logged_in,
                pc.box(
                    pc.vstack(
                        search_select(ServerState),
                        render_table(ServerState),
                        #pc.divider(),
                        #render_datatable(ServerState),
                    ),
                    width="100%",
                    border_width="0px",
                ),
                pc.link(
                    pc.button("먼저 로그인 하세요"),
                    href="/login",
                    color="rgb(107,99,246)",
                    button=True,
                )
            ),
            padding_top="5.5em",
            width="100%",
        ),
    )

def search_select(ServerState):
    """The Multi Select combo."""
    return pc.box(
        pc.hstack(
            pc.hstack(
                pc.select(
                    ServerState.select1_options,
                    placeholder="Select an Option.",
                    on_change=lambda value: ServerState.select_change('select1', value),
                    #is_disabled=True,
                    border_style="solid",
                    border_width="1px",
                    border_color="#43464B",
                    value=ServerState.select1_value,
                ),
                pc.select(
                    ServerState.select2_options,
                    placeholder="Select an Option.",
                    on_change=lambda value: ServerState.select_change('select2', value),
                    #is_disabled=True,
                    border_style="solid",
                    border_width="1px",
                    border_color="#43464B",
                    value=ServerState.select2_value,
                ),
                pc.button(
                    "검색", #Search
                    bg="navy", #gray
                    color="white", 
                    size="md",
                    width="150px",
                    on_click=ServerState.get_data_list,
                ),
                border_width="0px",
            ),
            border_width="0px",
        ),
        #position="fixed",
        #width="40%",
        #top="105px",
        z_index="500",
    )

def render_tbody_tr(tr_data, index):
    return pc.tr(
        pc.td(index + 1),
        pc.foreach(tr_data, lambda data: pc.td(data)),
    )

def render_table(ServerState):
    return pc.box(
        pc.vstack(
            pc.table(
                pc.table_caption(ServerState.table_name),
                pc.thead(
                    pc.tr(
                        pc.td("#"),
                        pc.foreach(ServerState.table_columns, lambda data: pc.td(data)),
                    ),
                    bg="navy",
                    color="white",
                ),
                pc.tbody(
                    pc.foreach(ServerState.table_data, lambda data, i: render_tbody_tr(data, i)),
                ),
            ),
            border_width="1px",
            overflow="auto",
        ),
        padding_top="1em",
        width="98%",
    )

def render_datatable(ServerState):
    return pc.box(
        pc.vstack(
            pc.data_table(
                data=ServerState.pd_result_list,
                pagination=True,
                search=True,
                sort=False, #True, False
                resizable=True,
                border_color="#43464B",
            ),
            width="100%",
            #align_items="start",
            #align_items="left",
            align_items=ServerState.box_align,
            #padding_x="15%",
            overflow="auto",
            #overflow_x="scroll",
            #overflow_y="scroll",
        ),
        border_width="0px",
        border_color="#43464B",
        width="98%",
    )

기본적인 화면 및 State 구성을 했으니 실제 로그인/로그아웃을 구현해보겠습니다.

 

로그인에 사용할 사용자 정보를 관리하기위해서 pynecone이 기본으로 제공하는 SQLite 파일 DB를 사용합니다.

pynecone docs에 보면 pc.session 을 통해서 SQLAlchemy syntax의 query로 Database에 데이터를 등록, 변경, 삭제, 조회 할수 있습니다. (https://pynecone.io/docs/database/queries)

"with pc.session" 으로 데이터처리를 하고 해당 block이 끝나면 자동으로 session도 닫게 됩니다.

 

이제 구현방법입니다.

- 먼저, State(pc.State) 에서 전 App에서 사용가능한 사용자 정보(이름, 패스워드, 권한)를 포함하는 User Class 생성하고 Base State에 로그인 한 사용자, 권한, 상태를 저장하는 vars를 생성

- demo_auth.py의 login에 사용자이름, 패스워드를 입력받는 로그인 Page 생성

- 사용자 등록하는 signup Page 생성 및 사용자 등록

- DB Browser for SQLite를 이용해서 직접 pynecondb에 붙어서 기본 사용자를확인 

 

프로젝트 명(폴더) : demo_board

# 파일 경로 설명 구분
1 pcconfig.py pc 구동 설정 파일  
2 demo_board > demo_board.py "/signup" 사용자 등록 Page 추가
"/servers" Page 추가
수정
3 demo_board > demo_state.py 나중 추가 확장성(페이지추가)를 위해 Base State(pc.State)  수정
4 demo_board > demo_auth.py "/login" 페이지 변경 및 "/signup" 페이지 추가 수정
5 demo_board > demo_servers.py 로그인후 오픈할 페이지 추가
6 demo_board > demo_helpers.py    

 

https://pynecone.io/docs/database/tables

 

demo_state.py 수정

import pynecone as pc

#table=True 인수는 Pynecone에게 이 클래스에 대해 데이터베이스에 테이블을 생성하도록 지시
class User(pc.Model, table=True): 
    """A table of Users."""

    username: str                 # 사용자명
    password: str                 # 패스워드
    userrole: str                 # 사용자 권한

class State(pc.State):
    """The base state for the app."""
    print("──────────────────── [ State(pc.State) ] ────────────────────")

    username: str
    userrole: str
    logged_in: bool = False       # 로그인/로그아웃시 상태를 저장

 

demo_auth.py 수정

Log in / Sign up 을위해서 추가 변경되는 부분이 많습니다.

import pynecone as pc

from .demo_state import State , User # Base State, User Class import
from .demo_helpers import navbar

# 로그인 호면에 대한 스타일 시트 적용
styles = {
    "login_page": {
        "padding_top": "10em",
        "text_align": "top",
        "position": "relative",
        "background_image": "bg.svg",
        "background_size": "100% auto",
        "width": "100%",
        "height": "100vh",
    },
    "login_input": {
        "shadow": "lg",
        "padding": "1em",
        "border_radius": "lg",
        "background": "white",
    },
}

class AuthState(State):
    """The Substate of base state for the login page."""
    print("──────────────────── [ AuthState(State) ] ────────────────────")
    app_name: str = "Log In"

    password: str           # 로그인에서 입력받은 Password

    new_username: str       # 신규 사용자 Name
    new_userrole: str       # 신규 사용자 Role
    new_password: str       # 신규 사용자 Password
    confirm_password: str   # 신규 사용자 Confirm Password

    message: str            # 사용자 등록시 발생하는 Error 등 메시지 표시
    message_color: str = "red" # Message의 기본색은 red, Sign up 성공인경우 navy

    def signup(self):
        """Sign up a user."""
        print("signup values : [{}], [{}], [{}]".format(self.new_username, self.new_password, self.new_userrole))
        with pc.session() as session:
            #self.message_color = "red"
            # 입력 값 Validation 체크
            if self.new_userrole == "" or self.new_userrole == None:
                self.message = "Select user role."
                return
            if self.new_username == "" or self.new_username == None:
                self.message = "Input user name."
                return
            if self.new_password == "" or self.new_password == None:
                self.message = "Input passwd"
                return
            if self.new_password != self.confirm_password:
                self.message = "Passwords do not match."
                return
            if session.exec(User.select.where(User.username == self.new_username)).first():
                self.message = "Username already exists."
                return

            user = User(username=self.new_username, password=self.new_password, userrole=self.new_userrole)
            session.add(user)
            session.commit()
            self.logged_in = False
            self.message_color = "navy"
            self.message = "User siginup completed, please log in"
            return pc.redirect("/login")

    def login(self):
        """Log in a user."""
        if self.username == "" or self.password=="":
            self.message = "Input username and password."
            return

        with pc.session() as session:
            user = session.exec(
                User.select.where(User.username == self.username)
            ).first()
            if user and user.password == self.password:
                self.logged_in = True
                self.userrole = user.userrole
                return pc.redirect("/servers")
            else:
                self.message = "Invalid username or password."
                return

    # Signup 화면에서 선택한 Role 정보를 AuthState에 저장
    def role_change(self,value):
        print("role_change value : {}".format(value))
        self.new_userrole = value

    def clear_message(self):
        self.message = ""
        self.message_color = "red"


def signup():
    return pc.box(
        pc.vstack(
            navbar(State, AuthState.app_name),
            pc.center(
                pc.vstack(
                    pc.heading("Sign Up", font_size="1.5em"),
                    pc.select(
                        ["User", "Admin"],
                        placeholder="Select a Role.",
                        on_change=lambda value: AuthState.role_change(value),
                        border_style="solid",
                        border_width="1px",
                        border_color="#43464B",
                    ),
                    pc.input(
                        on_blur=AuthState.set_new_username, placeholder="Username", width="100%", 
                        on_click=AuthState.clear_message,
                    ),
                    pc.input(
                        on_blur=AuthState.set_new_password,
                        placeholder="Password",
                        type_="password",
                        width="100%",
                        on_click=AuthState.clear_message,
                    ),
                    pc.input(
                        on_blur=AuthState.set_confirm_password,
                        placeholder="Confirm Password",
                        type_="password",
                        width="100%",
                        on_click=AuthState.clear_message,
                    ),
                    pc.button("Sign Up", on_click=AuthState.signup, width="100%"),
                    pc.text(AuthState.message, color=AuthState.message_color),
                ),
                style=styles["login_input"],
            ),
        ),
        style=styles["login_page"],
    )

def login():
    return pc.box(
        pc.vstack(
            navbar(State, AuthState.app_name),
            pc.center(
                pc.vstack(
                    pc.heading("Log In", font_size="1.5em"),
                    pc.input(
                        on_blur=State.set_username, placeholder="Username", width="100%", 
                        on_click=AuthState.clear_message,
                    ),
                    pc.input(
                        on_change=AuthState.set_password,
                        placeholder="Password",
                        type_="password",
                        width="100%",
                        on_click=AuthState.clear_message,
                    ),
                    # Username, Password를 입력받으면 AuthState의 login 함수 호출하는 event 작동
                    pc.button("Login", on_click=AuthState.login, width="100%"),  
                    # 사용자 등록하기위한 Signup
                    pc.link(
                        pc.button("Sign Up", width="100%"), href="/signup", width="100%"
                    ),
                    pc.text(AuthState.message, color=AuthState.message_color),
                ),
                style=styles["login_input"], # 로그인 Page에 맞는 추가 스타일 
            ),
        ),
        style=styles["login_page"],
    )

 

demo_servers.py

"/servers" 페이지는 pc.cond 컴포넌트로 Base State의 logged_in var가 True 일때만 열리게 구성합니다.

import pynecone as pc

from .demo_state import State # Substate of Base State
from .demo_helpers import navbar

class ServerState(State):
    """The Substate of base state for the server page."""
    print("──────────────────── [ ServerState(State) ] ────────────────────")
    app_name: str = "Servers" #1 Navigation Bar에 표시될 Page 명

def servers():
    return pc.box(
        pc.vstack(
            navbar(State, ServerState.app_name), #2 맨위 상단에 navbar를 추가 
            pc.cond(
                State.logged_in,
                pc.box(
                    pc.vstack(
                        pc.heading("서버 모니터링 페이지", font_size="2em"),
                    ),
                    width="100%",
                    border_width="0px",
                ),
                pc.link(
                    pc.button("먼저 로그인 하세요"),
                    href="/login",
                    color="rgb(107,99,246)",
                    button=True,
                )
            ),
            padding_top="5em",
            width="100%",
        ),
    )

로그인 없이 접근할경우

login 없이 접근할때

 

demo_board.py

"""Welcome to Pynecone! This file outlines the steps to create a basic app."""
from pcconfig import config

import pynecone as pc
from .demo_state import State
from .demo_auth import login, signup # Page 추가
from .demo_servers import servers

def index():
    return pc.center(
        pc.vstack(
            pc.heading("Pynecone Demo Board!", font_size="2em"),
            pc.link(
                "로그인",
                href="/login",
                border="0.1em solid",
                padding="0.5em",
                border_radius="0.5em",
                _hover={
                    "color": "rgb(107,99,246)",
                },
            ),
            spacing="1.5em",
            font_size="2em",
        ),
        padding_top="10%",
    )


# Add state and page to the app.
app = pc.App(state=State)
app.add_page(index)
app.add_page(login)
app.add_page(signup) # Page 추가
app.add_page(servers) # Page 추가
app.compile()

 

서버 재시작 (ctrl + c & pc run)

Log in page
Sign up page

 

서버 재구동후 DB Browser for SQLite 로 pynecone.db 파일을 열어보면 user 테이블이 생성된것을 확인

 

DB Browser for SQLite 를 닫고 사용자를 추가 (Sign up)

 

사용자 정보 입력 후 Sign Up
사용자 등록 완료후 로그인 페이지

pynecone.db에 저장된 사용자 확인 

(문서 만들기 시작할때는 낮이었는데 이제 밤이라 야간모드로 전환되었네요)

 

로그인

등록된 ID/PW 입력

로그인 성공 후 /servers Page로 이동

운영업무에서 사용할 대시보드를 pynecone으로 구현해볼려고 합니다.

기본적인 골격을 만들기위해서 아래 데모로 Navigation bar, 로그인, 매뉴, 다중 콤보, Table, DataTable 등 필요한 기능들을 하나씩 기능 테스트 및 구현해보고 있습니다.

 

개별 기능은 아래 '카테고리'에서 확인가능합니다. 

 

데모 구성은 https://amnesia.tistory.com/category/pynecone/demo%20board

 

'pynecone/demo board' 카테고리의 글 목록

 

amnesia.tistory.com

 

Demo

 

 

 

pynecone 버전 0.1.15
테스트 웹 브라우져 네이버 웨일, 구글 크롬(Chrome)

목표 : pynecone을 이용해서 업무에서 사용할 Business Dash Board를 구현

  • Reference Gallery Twitter Clone 샘플 참고
  • Common한 로그인 화면 구현
  • SQLite db(pynecone.db) 를 이용해서 사용자 관리하는 샘플을 구현

 

첫페이지

 

아이디 패스워드 입력
로그인 성공후 첫베이지
오른쪽 상단 매뉴 및 로그인 정보
SQLLite pynecone.db 에 등록된 사용자정보
사용자 추가 팝업
사용자 추가
#2 사용자가 추가됨

 

삭제할 사용자 번호(#) 입력후 "Delete"

 

 

#2 가 삭제됨

 

 

+ Recent posts