본문 바로가기

개발 프로젝트

파이썬으로 업무 프로그램 개발하기

728x90
 

Stack

PyQt5, Python

 

Preview

금융권은 망분리 정책으로 인해 개발 PC와 업무 PC가 주어지는데 이 두 PC는 모두 외부 인터넷과 접속이 차단이 됩니다.

개발용 PC와 메신저와 같은 업무를 처리하기 위한 업무용 PC가 주어집니다.

신입사원 때부터 인수인계 받은 "메뉴 권한 작업"이라는 업무는 직원들이 사용하는 업무 프로그램의 메뉴를 관리하는 작업입니다.

 

제가 재직하는 회사의 업무 프로그램은 임직원마다 접속할 수 있는 메뉴가 구별되어 있습니다. 이 권한 정보는 Mysql DB에 저장되어 있어서 만약 A라는 직원에게 특정 화면(메뉴)에 접근할 수 있도록 하려면 테이블에 해당 직원의 할당 업무 코드를 INSERT를 해야 하죠.

 

또한 새로운 메뉴가 생기면 그 메뉴가 업무 프로그램에 보이도록 GUI 프로그램(이하 menumanager)을 통해 작업해줘야 합니다.

 

이런 작업들이 좀 번거로워 개발을 하다가도 메뉴 관련 업무 문의가 들어오면 여간 귀찮은 일이 아니었습니다.

그래서 저의 개인적인 귀찮음을 해결하기 위해 번거로운 작업을 자동화하는 프로그램을 만들어보고자 처음엔 C# 프로그램을 짜서 돌려보려 했지만 C#은 .NET 프레임워크가 배포되는 PC에 다 설치가 되어있어야 한다는 제약 때문에(만약 프로그램을 배포하면 다른 직원 PC에서도 실행을 할 수 있어야 하기 때문) C++ MFC 프로그래밍을 시작했습니다. 하지만 익숙하지 않은 언어인 탓에 개발 시간이 오래걸려 다른 방법을 찾았습니다.

 

그래서 기존에 제가 학부생 때도 python을 좀 다뤘기 때문에 Pyqt라는 파이썬 GUI 프로그래밍 라이브러리가 생각나 시작해봤는데 C++ 보다 쉽고 재밌었습니다.

 

PyQt는 파이썬을 기반으로 개발할 수 있기 때문에 쉽습니다. 다만, C#이나 C++ MFC와 같이, 혹은 안드로이드 스튜디오처럼 컴포넌트 기반으로 사용자가 드래그 앤 드롭으로 화면을 구성하는 것이 아닌 직접 코드를 입력해 레이아웃을 배치해야 합니다.

 

물론 pydesigner 라는 툴을 활용하면 GUI 방식으로 개발할 수 있습니다.

 

이번 포스팅에서는 제가 개발한 프로그램이 어떤 방식으로 작동하고 어떻게 업무를 도와줄 수 있을지에 대해 소개하는 글을 적어볼까 합니다.

 

Methodology

가장 상단에는 DB커넥션을 위한 정보를 입력하는 레이아웃을 구성했고

탭 구조를 통해 이 업무에 필요한 기능을 구분해놨습니다.

조회에는 각 직원의 사번이나 이름을 통해서 해당 직원의 업무 코드를 조회하고 조회된 업무코드를 이용해 해당 직원의 메뉴 그룹을 조회 후 메뉴 그룹과 맵핑된 화면번호를 가장 하단에서 조회할 수 있습니다.

 

자세한 DB구조는 보안상 넘어가도록 하겠습니다. 이 화면에서는 조회뿐만 아니라 Delete, Update를 수행할 수 있는 지우기 버튼과 수정하기 버튼을 만들었습니다.

 

화면번호는 메뉴 번호를 말합니다. 예를 들어 

주식 현재가 화면은 #01001, 이체 화면은 #33300 이런 식으로 정의되어 있는데 이 화면을 볼 수 있는 할당 업무 코드를 가진 직원만 해당 화면을 조회할 수 있습니다. 따라서 이 화면엔 화면번호와 직원의 업무코드를 추가 해주는 기능을 합니다. 

 

이 탭은 txt 파일로 되어있는 메뉴 파일을 파싱 해 트리구조로 그려 드래그 앤 드롭으로 메뉴 작업을 할 수 있도록 도와주는 기능을 가진 탭입니다.

 

Code

조회 탭

이 화면에서 조회하는 테이블은 세 개 입니다. 

1. 임직원 정보가 저장되어 있는 테이블.

2. 할당업무코드와 부서업무코드가 매핑되어 있는 테이블.

3. 화면번호와 부서업무코드가 매핑되어 있는 테이블.

 

각 부서 혹은 직원마다 볼 수 있는 화면이 구분되어 있어 어떤 직원이 어떤 화면을 볼 수 있는지 조회할 수 있는 화면이 필요합니다. 

 

코드구조

 

-AutoQuery.py (Main Class)

-DBConnection.py (Class for DB Connection)

-FirstTab.py

-SecondTab.py

-ThirdTab.py

  -refDlg.py (Popup)

  -searchDlg.py (Popup)

  -addUser.py (Popup)

 

 

0.Main Class

공통 화면을 구성하는 클래스입니다. 

class MyWindow(QWidget):
    def __init__(self):
        super().__init__()
        
    def initUI(self):
        self.setWindowTitle("AutoQuery")
        self.makeUI()

    def makeUI(self):
        #UI 세팅
    
    def connectBtnClicked(self):
        dbC = dbConnection()
        dbObj = {
            'host' : addtext,
            'user' : self.idEdit.text(),
            'password' : self.pwEdit.text(),
            'dbName' : self.dbNameEdit.text(),
            'charset' : 'euckr'
        }
        dbC.setDBinfo(dbObj)  #dbConnection.py 에 DB param 전달
        dbC.openConn()
        if(dbC.isConnect):
            QMessageBox.about(self, 'Connection','Database connected Successfully')
         
        else:
            QMessageBox.about(self, 'Connection','Failed To Connect Database')
            

if __name__ == "__main__":
    app = QApplication(sys.argv)
    window = MyWindow()
    window.initUI()
    screen = app.primaryScreen()
    size = screen.size()
    w, h = 1200,800
    window.setGeometry(int(size.width()/2-w/2), int(size.height()/2-h/2), w,h) #모니터 사이즈를 고려한 윈도우 사이즈 조절
    window.show()

    app.exec_()

0.1 DBConnection.py

DB 커넥션을 담당하는 UI 부분이 상단 공통부분에 있어 어떤 탭에서든 접근하기 편하게 하기 위해 따로 빼두었습니다.

import MySQLdb as mdb

class dbConnection:
    def __init__(self):
        self.isConnect = False #DB가 붙어있는지 확인하기 위한 Flag
        self.mdb = mdb
    def setDBinfo(self, dbObj):
        self.host = dbObj['host']
        self.user = dbObj['user']
        self.password = dbObj['password']
        self.dbName = dbObj['dbName']
        self.charset = dbObj['charset']
        
    def openConn(self):
        try :
            self.db = mdb.connect(
                host = self.host,
                user = self.user,
                password = self.password,
                db = self.dbName,
                charset = self.charset
            )
            self.isConnect = True 

        except mdb.Error as e:
            self.isConnect = False

이로써 상단에 DB정보를 입력하고 connect 버튼을 누르면 dbConnection class 가 호출되며 connection을 시작합니다.

 

1. FirstTab.py

1.1 조회

def selectUserTBL(self):
    cursor = self.dbConnection.db.cursor() #dbConnection 파일에 전역변수로 저장된 db cursor를 불러온다.
    cursor.execute("SELECT * FROM User_Total_TBL Where ID like '%" +str(self.idEdit.text())+"%' or Node_Name='"+str(self.idEdit.text())+"' or Part_Code = '"+str(self.idEdit.text())+"'")
    rows = cursor.fetchall() #조회 데이터가 rows에 담긴다.
    self.clearUserTBL() #QTableWidget clear 해주는 함수
    
    if(len(rows)==0):
        QMessageBox.about(self,'alert','검색 내용이 존재하지 않습니다.')
    else:
        self.userTBL.setRowCount(len(rows)) #조회된 행 개수만큼 row를 생성
        self.userTBL.setColumnCount(len(rows[0])) #조회된 열 개수만큼 column 생성
        self.userTBL.setEditTriggers(QAbstractItemView.NoEditTriggers) #테이블 내용은 수정되지 않도록 설정
        self.userTBL.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeToContents) #header 사이즈 자동조절
        self.userTBL.setHorizontalHeaderLabels(['Seq','Root','Dept','Parent','Group_No','Node_Type','Node_Name','View_Yn','ID','Duty','Menu_No','Gender','Biz_Assign','Part','Duty_Code','Part_Code'])
        #컬럼명 설정
        for i in range(len(rows)):
            for j in range(len(rows[0])):
                self.userTBL.setItem(i, j, QTableWidgetItem(str(rows[i][j]))) #조회된 내용 테이블에 채워넣는다.
        self.userTBL.setSelectionBehavior(QAbstractItemView.SelectRows) #한 행이 전체 다 선택이 되도록 설정
        self.userTBL.itemDoubleClicked.connect(self.userTBLDblClicked) #더블클릭 했을 때 이벤트 동작하도록 지정

    self.dbConnection.db.commit() 
    cursor.close() #DB cursor close

1.2 삭제

def deleteUsrItem(self):
    if self.userTBL.currentRow() !=-1: #선택된 행이 있을 경우에만 동작하도록
        self.Qmsg = QMessageBox()
        self.Qmsg.setIcon(QMessageBox.Information)
        self.Qmsg.setWindowTitle('MessageBox')
        self.Qmsg.setText('선택한 행을 지우시겠습니까?')
        self.Qmsg.setStandardButtons(QMessageBox.Ok | QMessageBox.Cancel) #OK와 cancel을 선택할 수 있는 다이얼로그 띄움.
        retval = self.Qmsg.exec_()

        if(retval == QMessageBox.Ok): #OK를 누른경우 delete 문을 날려준다.
            id = self.userTBL.selectedItems()[8].text() #선택된 아이템의 정보를 가져와서 delete 문에 담는다.
            userName = self.userTBL.selectedItems()[6].text()
            try:
                sql = "DELETE FROM User_Total_TBL where ID='"+id+"' and Node_Name ='"+userName+"';"
                cursor = self.dbConnection.db.cursor()
                cursor.execute(sql)
                self.dbConnection.db.commit()
                cursor.close()
                QMessageBox.about(self, "MessageBox","Success")
                self.userTBL.removeRow(self.userTBL.currentRow()) #QTableWidget에서도 행을 지움.
            except self.dbConnection.mdb.Error as e:
                QMessageBox.about(self, "MessageBox",str(e))

1.3 업데이트

def scrTBLChanged(self,row, col):
    isdup = False
    for i in self.scrChangeList:
        if row == i[0] and col == i[1]:
            isdup = True

    if(isdup == True): return

    self.scrChangeList.append([row,col])

이 함수는

self.scrSearchTbl.cellChanged.connect(self.scrTBLChanged)

이 코드를 통해 호출됩니다. QTableWidget 의 내용이 수정됐을 때 동작하는 이벤트입니다.

수정할 내용이 있을 때마다 DB에 Update 문을 날리는 것이 아니라 수정됐을때마다 행과 열 정보를 담고 있다가 사용자가 수정버튼을 눌렀을 때 한번에 Update 하기 위함입니다.

def updateScrTBL(self):
    if len(self.scrChangeList) == 0 : return
    Qmsg = QMessageBox()
    Qmsg.setIcon(QMessageBox.Information)
    Qmsg.setWindowTitle('MessageBox')
    Qmsg.setText('수정하시겠습니까?')
    Qmsg.setStandardButtons(QMessageBox.Ok | QMessageBox.Cancel)
    retval = Qmsg.exec_()

    if(retval == QMessageBox.Ok):
        for i in self.scrChangeList:
            row = i[0]
            col = i[1]
            curItems = [] #바꿀 데이터
            prevItems = [] #원본 데이터
            for j in range(4):
                curItems.append(self.scrSearchTbl.item(row,j).text())
                prevItems.append(self.prevItems[row][j])

            try:
                sql = "UPDATE Scr_TBL SET Group_Name = '"+curItems[0] + "' ,Project_CLS = '" +curItems[1] + "' ,Use_CLS ='"+curItems[2] + "' ,Screen_No= '"+ curItems[3] +"' " 
                sql_wh = "where Group_Name = '"+prevItems[0] + "' and Project_CLS = '" +prevItems[1] + "' and Use_CLS ='"+prevItems[2] + "' and Screen_No= '"+ prevItems[3] +"'"
                cursor = self.dbConnection.db.cursor()
                cursor.execute(sql+sql_wh)
                self.dbConnection.db.commit()
                cursor.close()
                QMessageBox.about(self, "MessageBox","Success")
            except self.dbConnection.mdb.Error as e:
                QMessageBox.about(self, "MessageBox",str(e))
    self.scrSearchBtnClicked() #재조회
    self.scrChangeList = [] #changed list clear

1.4 버튼에 CSS 입히는 방법

QPushButton 을 override 해서 css만 입힌 코드입니다.

이렇게 클래스를 만들어두면 button = SelectButton('버튼이름') 으로 커스터마이징된 버튼을 만들 수 있습니다.

class SelectButton(QPushButton): 
    def __init__(self,text):
        super().__init__()
        self.setText(text)
        self.setStyleSheet("color : white;background-color: rgb(58,134,255);"
        "border-radius: 5px; width : '80px'; font-weight: bold; height: 18px")

 

2. SecondTab.py

이 탭은 INSERT 를 담당하는 화면입니다. 예를 들어 AAA123 , ABC232 부서업무코드를 가진 직원이 #12345 화면과 #54321 화면을 볼 수 있도록 해달라는 요청문의가 왔을 때

INSERT INTO Scr_TBL values ('AAA123','12345'),('AAA123','54321'),('AAA232','12345'),('AAA232','54321'); 와 같은 쿼리문을 짜는 대신 위처럼 담은다음에 버튼만 누르면 됩니다.

def insertBtnClicked(self):
    Qmsg = QMessageBox()
    Qmsg.setIcon(QMessageBox.Information)
    Qmsg.setWindowTitle('MessageBox')
    Qmsg.setText('추가하시겠습니까?')
    Qmsg.setStandardButtons(QMessageBox.Ok | QMessageBox.Cancel)
    retval = Qmsg.exec_()

    if(retval == QMessageBox.Ok):
        scrList = [self.scrListBox.item(x).text() for x in range(self.scrListBox.count())]
        #왼쪽 화면리스트박스에 담긴 데이터로 배열을 만든다.
        grpList = [self.grpNameBox.item(x).text() for x in range(self.grpNameBox.count())]
        #오른쪽 그룹리스트박스에 담긴 데이터로 배열을 만든다.
        sql_query1 = 'insert into Scr_TBL values '
        sql_query2 = ""
        isEnd = ","
        if len(scrList)>0 and len(grpList)>0:
            for i in range(len(scrList)):
                for j in range(len(grpList)):
                    if (i == (len(scrList)-1)) & (j==(len(grpList)-1)): isEnd = ";" #반복문의 끝에만 ';'를 달아야 하기 때문에
                    sql_query2 += '("%s", "B0","A","%s"),("%s", "B2","A","%s")%s' % (''.join(str(grpList[j])), ''.join(str(scrList[i])), ''.join(str(grpList[j])),''.join(str(scrList[i])),isEnd)
            try:
                cursor = self.dbConnection.db.cursor()
                sql = sql_query1+sql_query2
                cursor.execute(sql)
                self.dbConnection.db.commit()
                cursor.close()
                QMessageBox.about(self,'success',"success")
            except self.dbConnection.mdb.Error as e:
                QMessageBox.about(self,'fail',str(e))

 

3. ThirdTab.py - 메뉴 GUI 탭

 

세 번째 탭은 txt 파일로 되어있는 메뉴 파일을 파싱 해서 QTreeWidget을 사용하여 트리구조를 그리는 탭입니다.

이렇게 하면 GUI로 메뉴 작업을 할 수 있어 작업에 도움을 줍니다.

 

load 버튼을 누르면 파일입력창이 뜨고 메뉴 파일을 읽으면 다음과 같이 트리를 구성합니다.

원래 delphi로 개발된 프로그램이 있었는데 개발 소스도 없고 한번 메뉴 파일을 로드하는데 10~15분이 걸렸습니다.

물론 이 메뉴 개수가 대략 6300여 개 정도 되기 때문에 다 파싱을 해서 트리를 구성하려면 오래 걸리는 게 당연하지만 너무 오래 걸려서 불편함을 느끼기도 했습니다..

 

QTreeWidget 관련 해외자료는 많지만 국내자료가 많이 없더라고요.. 있어도 간단한 트리를 그리는 정도? 

원본 txt 파일은 다음과 같습니다. 너무 많기 때문에 일부분만 잘랐습니다.

간략하게 데이터를 들여다보면 행의 첫 번째 요소는 계층 순위를 의미합니다. 2번은 1번의 자식, 3번은 2번의 자식.. 이런 구조입니다.

 

3.1 파일 load

def loadFileBtnClicked(self):
    fname = QFileDialog.getOpenFileName(self, 'Open File','','Menu File(*.mnu);;') #.mnu 파일만 읽겠다.
    data = ""
    if fname[0] : 
        self.fileName = fname[0]
        f= open(fname[0], 'rt', encoding='cp949') 
        data = f.read()
        if(len(data)==0) : return
        self.changableState = False #트리의 행을 추가할 때마다 change이벤트가 발생하는 것을 막기 위한 flag
        #cell change 이벤트 내에서는 self.changableState 가 False 이면 return을 해주어 아무동작하지않음.
        self.menuTree.clear() #tree 초기화

        self.makeTree(data) #트리를 그려주는 함수로 데이터 토스

3.2 트리 그리기 

def makeTree(self,ndata):
    self.splited_data = ndata.split("\n") #데이터를 스플릿
    #데이터 초기화
    self.save_data = []
    self.itemArray = []
    #헤더( 2개 행) 은 고정된 값이므로
    self.save_data.append(self.splited_data[0]) #원본 데이터의 1, 2번째 행은 의미없기 때문에 따로 저장
    self.save_data.append(self.splited_data[1])
    self.itemArray.append(self.splited_data[0])
    self.itemArray.append(self.splited_data[1])
    
    root = self.menuTree.invisibleRootItem() #root 아이템을 저장한다. 최상위 아이템인셈이다.
    for i in range(2 , len(self.splited_data)):
        data = self.preprocessData(self.splited_data[i]) #전처리 과정을 거친다. 해당 함수는 하단 참고.
        
        if len(data[0])==0 : continue
        self.save_data.append(data) #원본 데이터를 전역변수에 저장해둔다. 
        
        level = data[0] #계층 순위 값. 즉 level
		
        #아래는 데이터를 정제하는 과정.
        self.scrGubun_map= {'00' : '일반화면', '01' : '종합화면', '02' : '웹화면', '03' : 'DLL화면','04':'Exe화면','05':'Function','06':'팝업화면', '07':'단독화면'}
        scrGubun = self.scrGubun_map[str(data[1][2:4])] if data[1]!='1100' else ""
        scrNum = data[2] if len(data[2].replace(" ",""))!=0 else ""
        topScr = data[3] if len(data[3].replace(" ",""))!=0 else ""
        fileName = data[4].rstrip() if len(data[4].replace(" ",""))!=0 else ""
        btnName = data[5] if len(data[5].replace(" ",""))!=0 else ""
        grade = data[6].rstrip() if len(data[6].replace(" ",""))!=0 else ""
        scrName = data[7].rstrip() if len(data[7].replace(" ",""))!=0 else ""
        menuGubun = data[8].rstrip() if len(data[8].replace(" ",""))!=0 else ""

        self.dosi_map = {"01": "예", "00": "아니오", "10": "아니오", "11":"예"}
        dosi = self.makedosiCombo(self.dosi_map[str(data[1][0:2])])

        if level == '1' :
            item1 = QTreeWidgetItem(root) #level이 1인 아이템은 root의 자식으로 붙인다.
            item1.setFlags(item1.flags() | QtCore.Qt.ItemIsEditable)
            self.setIconToItem(item1) #폴더 아이콘 그리기
            item1.setText(0, scrName)
            item1.setText(2, scrNum)
            item1.setText(3, topScr)
            item1.setText(5, fileName)
            item1.setText(6, btnName)
            item1.setText(7, grade)
            item1.setText(8, menuGubun)
            item1.setData(1, Qt.UserRole, level)
            self.menuTree.setItemWidget(item1, 1, dosi)
            self.itemArray.append(item1) #아이템만 따로 저장하는 전역변수를 둔다.


        elif level >= '2' : #level이 2이상이면 누군가의 즉, 부모의 자식아이템인것이다. 
            parent = locals()['item{}'.format(int(level)-1)]
            child = locals()['item{}'.format(level)] = QTreeWidgetItem(parent)
            child.setFlags(child.flags() | QtCore.Qt.ItemIsEditable) #아이템을 수정할 수 있도록 설정 추가
            child.setData(1, Qt.UserRole, level)
            self.menuTree.setItemWidget(child, 1, dosi)
            self.addChildToParent(child, parent, data) #이 함수가 자식을 부모에 붙이는 함수(하단 참고)

    self.changableState = True #트리를 다 그리고 나면 이제 change 이벤트가 동작하도록 플래그값을 변경해준다.

3.3 데이터 전처리

cp949 로 인코딩 후 글자 수대로 자른 다음 다시 디코딩해서 넣는 이유는 원본 데이터는 바이트 수대로 데이터가 구분되어 있습니다. 이 바이트를 기준으로 업무 프로그램이 메뉴 파일을 읽어 GUI를 구성하기 때문에 원본 데이터의 바이트 수(규칙)를 깨뜨리면 안 됩니다. 

그래서 cp949로 인코딩을 하면 한글이나 숫자, 띄어쓰기의 개수(바이트)가 같아져 substring 하기 편리해집니다.

혹, 이 과정이 오래걸리는건 아닐까? 싶었지만 크게 차이는 인코딩을 한 것과 하지 않은 것의 시간 차이는 미미했습니다.

def preprocessData(self, splited_data):
    splited_data = splited_data.encode('cp949')
    nSplited_data = []
    nSplited_data.append(splited_data[0:1].decode('cp949')) #레벨 : 1
    nSplited_data.append(splited_data[2:6].decode('cp949'))#화면구분 및 도시여부: 4 (앞 두자리 00 : "아니오", 01 : "예", 뒷 두자리 00 : 일반화면, 01 : 종합화면, 02: 웹화면, 03 :DLL화면, 04 : Exe화면, 05: Function, 06: 팝업화면, 07: 단독화면)
    nSplited_data.append(splited_data[7:12].decode('cp949'))#화면번호 : 5_뒤에서부터 read
    nSplited_data.append(splited_data[13:18].decode('cp949'))#상위화면 : 5(5)
    nSplited_data.append(splited_data[19:59].decode('cp949'))#파일명 : 40(40)
    nSplited_data.append(splited_data[60:68].decode('cp949'))#버튼이름 : 4(8)
    nSplited_data.append(splited_data[69:89].decode('cp949'))#등급 : 20(20) (0123456789ABCDEFGXYZ)
    nSplited_data.append(splited_data[90:150].decode('cp949'))#화면이름 : 60
    nSplited_data.append(splited_data[151:171].decode('cp949'))#메뉴구분 : 20(20)
    return nSplited_data

 

3.4 자식 아이템을 부모 아이템의 하위에 붙인다.

def addChildToParent(self, child, parent, data):

	#데이터 전처리
    scrNum = data[2] if len(data[2].replace(" ",""))!=0 else ""
    topScr = data[3] if len(data[3].replace(" ",""))!=0 else ""
    fileName = data[4].rstrip() if len(data[4].replace(" ",""))!=0 else ""
    btnName = data[5] if len(data[5].replace(" ",""))!=0 else ""
    grade = data[6].rstrip() if len(data[6].replace(" ",""))!=0 else ""
    scrName = data[7].rstrip() if len(data[7].replace(" ",""))!=0 else ""
    menuGubun = data[8].rstrip() if len(data[8].replace(" ",""))!=0 else ""
    
    child.setText(0, scrName)
    child.setText(2, scrNum)
    child.setText(3, topScr)
    child.setText(5, fileName)
    child.setText(6, btnName)
    child.setText(7, grade)
    child.setText(8, menuGubun)
    parent.addChild(child) #parent 아이템을 파라미터로 받아 child 아이템을 addChild 해주면 된다.
    self.menuTree.setItemWidget(child, 4, scrGubunCb)#이 Combobox 는 child 아이템에 추가한다음 부모에 붙여도 안붙는다..
    self.itemArray.append(child) #아이템을 저장하는 전역변수에 자식 아이템도 추가

scrGubunCb는 콤보 박스입니다. 자식 아이템에 setText 할 때 같이 붙이면 좋으련만.. 이상하게 그런 기능이 없더라고요.. 꼭 QTreeWidget의 setItemWidget 함수로 첫 번째는 아이템, 두 번째는 칼럼, 세 번째는 콤보 박스를 넘겨줘야 붙습니다.

 

여기서 잠깐, 왜 원본 데이터를 전역 변수로 저장해두었을까요?

이 탭에서는 폴더(부모 아이템)를 추가할 수도, 파일(자식 아이템)을 추가할수도 있고 drag, drop으로 아이템의 위치도 바꿀 수 있을 정도로 자유도가 높습니다. 만약 이렇게 아이템의 개수, 위치가 변경되었을 때 나중에 한 번에 저장하려고 하면 모든 행을 돌아서 다시 원본 데이터와 같이 변환한 다음 저장해야 합니다. 그렇지 않으면 업무 프로그램이 읽을 수가 없기 때문입니다. 

따라서, 아이템이 추가되거나 위치가 바뀌었을 때마다 원본 데이터도 수정을 해주어서 나중에 저장버튼을 누르면 전역 변수로 저장되어 있던 원본 데이터만 그대로 저장하면 됩니다. 저장하는데 시간을 단축시킬 수 있습니다. 

이 메뉴 파일을 로드하는데 빠르면 30초에서 1분 정도 걸립니다. 그만큼 파싱 할 데이터가 많기 때문입니다. 만약 저장할 때도 이 정도의 시간이 걸린다면 안 되겠죠.

 

3.5 트리 아이템의 변경

def itemDoubleClicked(self,item, column): #dblClick 했을 때 원본 아이템 텍스트를 저장해둔다.
    self.before_changed_Item = item.text(column)
    self.changableState = True #change 가 가능하도록 flag 변경

def treeItemChanged(self,item,column):
    if self.changableState == False :return 
    else:
        row = self.getRowOfItem(item) #아이템이 트리에서 몇 번째 행에 있는지 알기 위한 함수. 하단 참고
        text = item.text(column)
        column_map = {0:7, 2: 2, 3:3, 5:4, 6:5, 7:6, 8:8}  #원본 데이터와 트리 컬럼 순서가 다르다.
        original_data = self.save_data[row][column_map[column]] 
        
        #바꿀 내용이 없다면 return, 길이가 원본보다 크다면
        if self.before_changed_Item == text:
            return
        if len(text)>len(original_data): #원본 데이터는 바이트 수대로 잘랐기 때문에 이 크기를 초과하면 안되기 때문
            QMessageBox.about(self,'MessageBox','정해진 길이를 초과합니다.')
            self.menuTree.currentItem().setText(column, self.before_changed_Item)
            return
        #원본 데이터의 길이만큼 바꿀단어와 공백으로 채워줘야 함.
        for i in range(len(text.encode('cp949')), len(original_data.encode('cp949'))):
            text += " "

        self.save_data[row][column_map[column]] = text #원본 데이터도 변경

** 선택된 아이템이 트리에서 몇 번째 행인지 구하는 방법

def getRowOfItem(self,item):
    return self.itemArray.index(item)

원본 데이터를 저장하는 데 사용한 전역 변수는 self.save_dataself.itemArray입니다. save_data는 문자열로 이루어진 1차원 배열입니다. 

self.save_data

self.itemArray 는 QTreeWidgetItem 객체들로 이루어진 1차원 배열입니다.

getRowOfItem 은 이 self.itemArray 에서 해당 아이템의 순번을 return 해줍니다.

 

여기서 잠깐, 그냥 QTreeWidget.indexFromItem(item)을 쓰면 안 되나요?

QTreeWidget 은 root라고 생각하면 됩니다. 만약 item 이 계층 순위(level) 이 3이라서 2의 자식 아이템이라고 한다면, 이 함수는 부모 아이템의 부모인 1 아이템의 행을 리턴합니다. 즉 본인의 행 index가 아닌 최상위 부모의 index를 리턴합니다. 그래서 self.itemArray라는 일차원 배열에서 찾기로 결정했습니다.

 

3.6 아이템 추가 및 삭제

 

마우스 우클릭하면 커스텀 context 가 뜨면서 폴더나 파일 아이템을 추가할 수 있습니다.

콘텍스트 구현하는 방법을 보겠습니다.

 

self.menuTree.customContextMenuRequested.connect(self.menuContextRightClick)

QTreeWidget.cunstomContextMenuRequested.connect 에 이벤트 함수를 파라미터로 넘겨주면 됩니다.

def menuContextRightClick(self,event):
    self.menu_context = QMenu(self.menuTree) #QMenu 
    clickmenu_addfile = self.menu_context.addAction("자식 아이템 추가")
    clickmenu_addfolder = self.menu_context.addAction("자식 폴더 추가")
    clickmenu_delete = self.menu_context.addAction("메뉴 삭제")
    action2 = self.menu_context.exec_(self.menuTree.mapToGlobal(event))
    if action2 is not None :
        parent = self.menuTree.currentItem()
        childItem = QTreeWidgetItem()
        isFolder = self.isFolder(self.getRowOfItem(parent))
   

        self.changableState = False #아이템이 추가될때 change 이벤트가 발생하는 것을 막기위함

        if action2 == clickmenu_addfile: #파일 추가시
            if not isFolder: return     #폴더가 아닌 파일은 자식 아이템을 만들수 없다.
            self.addChildItemToParent(parent, None, False)

        elif action2 == clickmenu_addfolder: #폴더 추가시
            if not isFolder: return
            self.addChildItemToParent(parent, None, True)

        elif action2 == clickmenu_delete: #제거 시
            deleteItem = self.menuTree.currentItem()
            self.deleteItemFromParent(deleteItem)

addChildItemToParent 함수를 보겠습니다.

def addChildItemToParent(self, targetItem, childItem, isTargetFolder):
        childItem = QTreeWidgetItem()
        default_dosi = '1100'
        targetItemRow = self.getRowOfItem(targetItem)
        if isTargetFolder == False:
            #화면구분 콤보박스
            scrGubunCb = self.makeScrGubunCombo(0)
            scrGubunCb.currentIndexChanged.connect(self.on_combobox_changed_scrGubun)
            default_dosi= '0100'
            childItem.setExpanded(False)

        #부모 아이템에 붙인다
        targetItem.insertChild(targetItem.childCount(), childItem) #맨 마지막에 붙여야 하므로
        childItem.setFlags(childItem.flags() | QtCore.Qt.ItemIsEditable)
        
        if isTargetFolder == False : self.menuTree.setItemWidget(childItem, 4, scrGubunCb)
        else:
            self.setIconToItem(childItem) #폴더 아이콘 삽입

        childItem.setText(6, "버튼이름")
        childItem.setText(7, "Z")
        childItem.setData(1, Qt.UserRole, int(targetItem.data(1, Qt.UserRole))+1)

        dosi = self.makedosiCombo("예")
        self.menuTree.setItemWidget(childItem, 1, dosi)

        #포커스를 새로 만들어진 아이템으로 이동한다.
        self.menuTree.scrollToItem(childItem)
        childItem.setSelected(True)

        #원본 데이터 배열에 끼워넣는다.
        self.save_data.insert(targetItemRow+self.getChildrenItemCount(targetItem,[]), [
            #부모 아이템의 레벨에 따라 레벨이 정해짐
            str(int(targetItem.data(1, Qt.UserRole))+1),
            default_dosi,
            '00000',
            '     ',
            '                                        ',
            '버튼이름',
            'Z                   ',
            '                                                            ',
            '                    '
        ])
        self.itemArray.insert(targetItemRow+self.getChildrenItemCount(targetItem,[]),childItem)

마지막 인자로 추가하려는 아이템이 폴더(부모) 인지 파일(자식)인지 구분하기 위한 파라미터를 받습니다.

이 함수 내에서 유의 깊게 봐야 하는 포인트는 마지막에 원본 데이터에 붙이는 방법입니다. 

self.itemArrayQTreeWidgetItem으로 이루어진 1차원 배열이라고 했습니다. 그렇기 때문에 새롭게 추가되는 아이템이 어디에 붙어야(몇 번째 행) 할지 알아야 합니다. targetItemRow는 앞서 말씀드린 대로 self.getItemRow로 알면 되지만 부모 아이템의 가장 마지막 자식으로 붙여야 하기 때문에 부모 아이템의 모든 자식 개수를 알아야 합니다.

하지만, 여기서도 QTreeWidgetItem을 쓰면 되지 않나 싶었지만, 역시나 자식의 자식 개수까지 포함하진 않습니다.

 

예를 들어, 이런 구조가 있을 때 Item1의 childCount()를 하면 1이 나옵니다. Item 2 만 센 것이죠. 따라서 저는 자식 아이템을 구하는 함수를 만들었습니다. 자식이 없을 때까지 다 탐색해야 하기 때문에 DFS 방식을 선택했습니다.

#item 의 하위 아이템 개수를 총합해주는 DFS 함수
def getChildrenItemCount(self,node,child_count = []):
    child_count.append(node.childCount()) #child_count라는 배열에 아이템의 자식개수를 넣는다.
    if node.childCount() > 0: #자식이 있다면
        children = []
        for i in range(node.childCount()): children.append(node.child(i))
        for child in children: 
            self.getChildrenItemCount(child, child_count)

    return sum(child_count) #child_count 배열에 쌓인 데이터의 합. 즉 자식의 개수합

 

self.deleteItemFromParent 함수도 보겠습니다.

def deleteItemFromParent(self, deleteItem):
    row = self.getRowOfItem(deleteItem)
    #save data 배열에서도 제거
    #folder 가 제거되면 하위 아이템도 다 제거해야한다.
    delCount = 1 + self.getChildrenItemCount(deleteItem, []) #본인을 포함

    for i in range(delCount):
        del self.save_data[row]
        del self.itemArray[row]

    #deleteItem이 부모이면 self.menuTree 에서 부모를 지워줘야 한다.
    if deleteItem.parent() is None : #부모가 없다는건 본인이 최상위 아이템이기 때문에 root에서 지워줘야한다.
        self.menuTree.takeTopLevelItem(self.menuTree.currentIndex().row())
    else: deleteItem.parent().removeChild(deleteItem)

 

3.7 아이템 위치 변경 Drag, Drop

고려해야 할 경우의 수가 4가지입니다. (파일=자식, 폴더는 부모) 

1. 파일에서 파일로의 이동 

2. 파일에서 폴더로의 이동

3. 폴더에서 파일로의 이동

4. 폴더에서 폴더로의 이동

추가로, 본인의 부모 아이템으로 이동하는지, 다른 부모 아이템의 자식으로 이동하는지에 차이도 있습니다. 

1. 파일에서 파일로의 이동

파일(SelectedItem)에서 파일(TargetItem)로의 이동은 TargetItemTargetItem 위에 가져다 놓으면 됩니다.

 

2. 파일(SelectedItem)에서 폴더(TargetItem)로의 이동은 TargetItem의 마지막 자식 밑에 SelectedItem을 붙이면 됩니다.

3. 폴더에서 파일로의 이동은 파일에서 파일로의 이동처럼 TargetItem의 위에 가져다 놓으면 됩니다. 

4. 폴더에서 폴더로의 이동은 SelectedItem과 그 자식 아이템들을 모두 TargetItem의 마지막 자식 밑에 붙이면 됩니다.

 

우선 QTreeWidgetdrag, drop을 구현하는 방법은 다음과 같습니다.

class TreeWidget(QTreeWidget): 
    def __init__(self, parent= None):
        QTreeWidget.__init__(self, parent)
        self.setDragDropMode(QAbstractItemView.InternalMove) 
        self.setSelectionMode(QAbstractItemView.SingleSelection)
        self.setAcceptDrops(True)
        self.setDragEnabled(True)
        self.setDropIndicatorShown(True)
        self.thirdTabCls = parent #이 QTreeWidget 을 호출한 대상

    def keyPressEvent(self, event):
        if event.key() == QtCore.Qt.Key_Delete:
            self.thirdTabCls.deleteItemFromParent(self.currentItem()) #del 키가 눌렸을 때 delete가 발생하도록
        else:
            super().keyPressEvent(event)

    def dropEvent(self, event):
        self.thirdTabCls.changableState=False #drop 이벤트 발생시에도 change 이벤트 발생을 막기 위함.
        if event.source() == self:
            QAbstractItemView.dropEvent(self, event)
    def dropMimeData(self, parent, row, data, action): 
        if action == Qt.MoveAction:
            return self.moveSelection(parent, row)
        return False
    def moveSelection(self, targetItem, row):
        if targetItem is None : return False #drop을 QTreeWidget 영역에서 벗어난 곳에 하는 경우 대비
        selectedItem = self.currentItem() 
        self.thirdTabCls.moveSelectItemToTargetItem(targetItem, selectedItem)

        return False

QTreeWidget Class를 별도로 구현해서 커스터마이징 했습니다. MimeData는 클립보드에 저장되는 데이터라고 하네요

QMimeData is used to describe information that can be stored in the clipboard 

마지막 함수 moveSelection에서 row와 targetItem을 받아 제가 만든 함수 moveSelectItemToTargetItem에 보내도록 되어 있는데 마지막 return False는 어떤 것을 의미할까요?

-return True 하면 기본적으로 QTreeWidget에 내장되어 있는 Drag & Drop 이벤트가 발생하면서 제가 원하지 않는 방법으로 파일이 정렬됩니다. 그래서 저는 제가 원하는 대로 파일이 위치되어야 하기 때문에 직접 구현한 함수로 selectedItemtargetItem을 보냈습니다.

 

moveSelectItemToTargetItem 함수를 보죠.

def moveSelectItemToTargetItem(self, targetItem, selectedItem):
    targetItemIndexOfParent, selectedItemIndexOfParent, targetItemParent, selectedItemParent= None,None,None,None

    if targetItem.parent() is None: #part 1
        targetItemParent = self.menuTree.invisibleRootItem()
        targetItemIndexOfParent = targetItemParent.indexOfChild(targetItem)
    else: 
        targetItemParent = targetItem.parent()
        targetItemIndexOfParent = targetItemParent.indexOfChild(targetItem)

    if selectedItem.parent() is None:
        selectedItemParent = self.menuTree.invisibleRootItem()
        selectedItemIndexOfParent = selectedItemParent.indexOfChild(selectedItem) 
    else: 
        selectedItemParent = selectedItem.parent()
        selectedItemIndexOfParent=selectedItemParent.indexOfChild(selectedItem)

    targetItemIndexOfTree = self.getRowOfItem(targetItem) #part 2
    selectedItemIndexOfTree = self.getRowOfItem(selectedItem)

    targetItemChildrenCount = self.getChildrenItemCount(targetItem, [])
    selectedItemChildrenCount = self.getChildrenItemCount(selectedItem,[])

    #itemArray 와 save_data 를 초기화하기 위함 #part 3
    selectedChildren = self.itemArray[selectedItemIndexOfTree : selectedItemIndexOfTree+selectedItemChildrenCount+1]
    selectedChildren_save_data = self.save_data[selectedItemIndexOfTree : selectedItemIndexOfTree+selectedItemChildrenCount+1]

    if self.isFolder(targetItemIndexOfTree) :  #part 4
        targetItem.addChild(selectedItemParent.takeChild(selectedItemIndexOfParent))
        #selected 가 target의 자식이면 target의 맨 마지막 자식 뒤에 붙는다.

        for i in range(len(selectedChildren)): #part 5
            insertIndex = targetItemIndexOfTree+targetItemChildrenCount
            if selectedItemIndexOfTree>targetItemIndexOfTree:  #part 6
                if selectedItemIndexOfTree <= targetItemIndexOfTree + targetItemChildrenCount : #sel이 tar의 자식이면
                    self.itemArray.insert(insertIndex, self.itemArray.pop(selectedItemIndexOfTree))
                    self.save_data.insert(insertIndex, self.save_data.pop(selectedItemIndexOfTree))
                    self.makeDosiCbToItem(insertIndex)
                    if not self.isFolder(insertIndex):
                        self.makeScrCbToItem(insertIndex)
                else:
                    insertIndex += (i+1)
                    self.itemArray.insert(insertIndex, self.itemArray.pop(selectedItemIndexOfTree+i))
                    self.save_data.insert(insertIndex, self.save_data.pop(selectedItemIndexOfTree+i))
                    self.makeDosiCbToItem(insertIndex)
                    if not self.isFolder(insertIndex):
                        self.makeScrCbToItem(insertIndex)

            else:
                self.itemArray.insert(insertIndex, self.itemArray.pop(selectedItemIndexOfTree))
                self.save_data.insert(insertIndex, self.save_data.pop(selectedItemIndexOfTree))
                self.makeDosiCbToItem(insertIndex)
                if not self.isFolder(insertIndex):
                    self.makeScrCbToItem(insertIndex)


            self.resetLevelOfSaveData(insertIndex, targetItemIndexOfTree, True)

    else:  #part 7
        #같은 폴더에 있는 경우 요소가 takechild 가 먼저 되기 때문에 indexing에 오류가 생김. selected가 target 보다 index가 먼저이면 target의 index가 -1됨.
        if selectedItemIndexOfParent< targetItemIndexOfParent : 
            if selectedItemParent == targetItemParent:
                targetItemIndexOfParent -=1
        #targetItem 이 폴더가 아니라면 같은 레벨로 이동한다.
        targetItemParent.insertChild(targetItemIndexOfParent,selectedItemParent.takeChild(selectedItemIndexOfParent))

        for i in range(len(selectedChildren)):  #part 8
            insertIndex = targetItemIndexOfTree+i
            if selectedItemIndexOfTree>targetItemIndexOfTree:
                self.itemArray.insert(insertIndex, self.itemArray.pop(selectedItemIndexOfTree+i))
                self.save_data.insert(insertIndex, self.save_data.pop(selectedItemIndexOfTree+i))
                self.makeDosiCbToItem(insertIndex)

                if not self.isFolder(insertIndex):
                    self.makeScrCbToItem(insertIndex)
            else:
                insertIndex = targetItemIndexOfTree-1
                self.itemArray.insert(insertIndex, self.itemArray.pop(selectedItemIndexOfTree))
                self.save_data.insert(insertIndex, self.save_data.pop(selectedItemIndexOfTree))
                self.makeDosiCbToItem(insertIndex)
                if not self.isFolder(insertIndex):
                    self.makeScrCbToItem(insertIndex)

주석에 파트별로 나눠놨으니 자세히 들여다보면

 

Part1. targetItemselectedItem 이 최상위 폴더인지 아닌지 분기를 합니다. 그래서 자신의 부모에서 몇 번째에 있는지 저장해둡니다.

Part2. Tree에서 절대적으로 몇 번째에 있는지 저장해둡니다. 자식의 개수까지도요. 

Part3. selectedItem의 모든 자식과 개수, targetItem의 모든 자식과 개수를 가지고 있습니다. 폴더 이동의 경우 자식 아이템을 다 가져가야 하기 때문입니다. 당연히 QTreeWidget 자체적으로 부모 아이템을 이동하면 자식 아이템들도 다 이동을 시키지만 저는 전역 변수에 원본 데이터를 저장해뒀기 때문에 이동이 일어날 경우 해당 원본 데이터도 수정을 해줘야 하기 때문입니다. 원본 데이터는 1차원 배열이기 때문에 자식 아이템을 다 이동하려고 하면 item Index와 개수를 알아야 합니다.

Part4. 여기서부터는 targetItem이 폴더인지 아닌지 분기합니다. parent에서 takeChild 만으로 QTreeWidget 내의 작업은 완료됩니다. 그 이후는 원본 데이터를 수정하기 위한 코드입니다.

Part5. 이동하려는 위치(selectedItemIndex)를 가지고 있습니다.

이 메뉴 GUI를 1차원 배열로 나타내면 다음과 같습니다.

["(1) 테스트 메뉴" , "(2) 주요 기능 설명", "(2) 버전" , "(3) 표준메뉴", "(3) 간편 메뉴", "(3) 기본 모드"] 

앞 (numb)는 계층 순위(level)를 의미합니다.

만약 기본 모드를 테스트 메뉴로 이동하면 주요 기능 설명 아래로 이동해야 합니다.

제가 사용한 방법은 array.pop() 한 아이템을 array.insert()로 이동하는 것입니다. 위 예제대로 하려면

array.insert(2, array.pop(5)) 를 하면 되겠죠. 하지만, 버전을 테스트 메뉴로 이동하게 하려면 어떻게 해야할까요? 

우선 버젼 폴더가 주요 기능 설명 아래로 이동합니다.

버전의 index는 2입니다. 자식은 세 개입니다.

for i in range 4 : array.insert(2, array.pop(2))

이렇게 하면 array에서 두 번째 인자가 계속 빠지면서 두 번째 인자로 insert 되겠죠. 결국 제자리에 있겠죠.

이 예시가 너무 단순해서 그렇지만 이 메뉴 파일은 수천 개의 폴더와 파일로 이루어져 있기 때문에 많은 경우의 수가 있습니다.

지금처럼 targetItem보다 selectedItem의 index가 뒤에 있는 경우도 있지만 selectedItem의 index가 앞에 있는 경우가 있습니다. 또한 한 부모 아이템 내에서 자식 아이템(폴더) 간의 이동이 일어날 수도 있습니다. 그렇기 때문에 selectedItem과 targetItem의 위치를 비교해서 분기를 하고 같은 부모 아래인지 아닌지 분기하는 과정 및 배열 편집이 part6, 7, 8에 담겨있습니다. 

*다시 한번 말하지만, 이렇게 하는 이유는.. 단순히 저장시간을 줄이기 위함입니다.. 더 좋은 방법이 있겠지요.. 알고 계신 분은 가르쳐주세요ㅠ 아직 많이 배워야 하는 단계입니다.

 

 

3.8 트리 아이템의 검색 기능

검색은 QTreeWidget.findItems를 사용했습니다.

def findMenu(self, text, idx):
    self.items = self.menuTree.findItems(text, Qt.MatchContains | Qt.MatchRecursive,0)
    self.items += self.menuTree.findItems(text, Qt.MatchContains | Qt.MatchRecursive,2)
    self.itemcount = len(self.items)
    if self.itemcount == 0:
        QMessageBox.about(self,'MessageBox',"찾을 내용이 존재하지 않습니다")
        self.dlg.moreBtnEnabled(False)
        return
    elif self.itemcount==1:
        self.item = self.items[idx]
        self.menuTree.scrollToItem(self.item)
        self.item.setSelected(True)
        self.dlg.moreBtnEnabled(False)
    else:
        if idx >= self.itemcount: 
            QMessageBox.about(self,'MessageBox',"더이상 찾을 내용이 존재하지 않습니다")
            self.dlg.moreBtnEnabled(False)
            return
        else:
            self.item = self.items[idx]
            self.menuTree.scrollToItem(self.item)
            self.item.setSelected(True)
            self.dlg.itemcount = idx+1
            self.dlg.moreBtnEnabled(True)

Qt.MatchContains 는 검색 키워드가 포함된 아이템을 모두 찾는 것이고 마지막 num 인자는 칼럼을 의미합니다. 저는 화면 이름과 번호만 검색이 되도록 하기 위해서 저렇게 짰습니다. 그리고 보시다시피 한번 검색한 후에 더 있으면 엑셀처럼 다음 검색 버튼이 활성화되면서 계속 아래로 찾아나가야 합니다. 

 


 

긴 글 읽어주셔서 감사합니다.