2016年2月1日 星期一

如何在 GAE 上佈署 jQuery EasyUI 專案 (六) : EasyUI CMS on GAE 之 1

在 1 月的最後一天終於把 EasyUI CMS on GAE 搞定了, 這兩天花了點時間把測試過程紀錄如下, 以資往後查考之用 (否則一個月之後就會漸漸忘記為啥要這樣寫). 這項測試其實是 1/12 開始進行的, 因為那天下午才要去公司, 早上在家繼續玩 GAE 測試, 本來想測試留言板功能, 但是有一股力量拉著我直攻本次臨時起 "義" 的目標~~把 EasyUI CMS 移植到 GAE 上! 完成後再把留言板功能建在 CMS 裡面. 但接下來有資安的公事要處理, 分心研究了 TinyMCE 網頁編輯器, 又被指派去台北簡報, 將近兩個禮拜沒進度, 到 1 月底才又繼續.

在此次測試過程中發現, EasyUI 最近已經升版為 1.4.4, 之前利用 CDN 取得 jQuery 與 EasyUI 資源在 1.4.3 版測試的結果到了 1.4.4 版就會先後出現如下錯誤訊息 :

TypeError: $.data(...) is undefined
TypeError: $.fn.tabs.methods[_37a] is not a function

有可能 EasyUI 有發現這些問題正在修改, 才會先後出現不同錯誤訊息. 為了測試能順利進行 (沒時間去研究新版的變動), 只好自備 1.4.3 版的資源檔在 /static/easyui 下面, 結構如下 :


同時改寫 template/jqueryeasyui.htm 這個模板如下 :

{% extends "html5.htm" %}
{% block link %}
  <link id="theme" rel="stylesheet" href="http://www.jeasyui.com/easyui/themes/default/easyui.css">
  <link rel="stylesheet" href="http://www.jeasyui.com/easyui/themes/icon.css">
{% endblock %}
{% block javascript %}
  <script type="text/javascript" src="http://www.jeasyui.com/easyui/jquery.min.js"></script>
  <script type="text/javascript" src="http://www.jeasyui.com/easyui/jquery.easyui.min.js"></script>
  <script type="text/javascript" src="http://www.jeasyui.com/easyui/locale/easyui-lang-zh_TW.js"></script>
{% endblock %}

事實上我是先將原來使用 CDN 的 jqueryeasui.htm 更名為 jqueryeasyui_cdn.htm, 然後才去修改的, 改好後又將其複製為 jqueryeasyui_self.htm, 這樣可以方便切換兩種供檔方式. 切換時先將現在使用中的 jqueryeasui.htm 刪除, 然後複製上面兩個來源檔, 改名為 jquerywasyui.htm 即可.

本篇測試是在下面第一篇測試的基礎上添加的 :

# 如何在 GAE 上佈署 jQuery EasyUI 專案 (五) : 紀錄到訪者
如何在 GAE 上佈署 jQuery EasyUI 專案 (四) : Google 登入

首先我參考了之前使用 PHP 版的文章先做好 GAE 版的版面配置, 記得當時可是花了一整天才調好自訂的樣式表, 得到了一個滿意的版面, 參考下面這篇 PHP 版的 :

用 jQuery EasyUI 打造輕量級 CMS (一)

在 PHP 版的 CMS 裡, 此網頁稱為 main.php, 而在 GAE 是利用 Python 程式的 main.py 去渲染網頁模板, 所以我把 CMS 的網頁模板取名為 main_1.htm, 初步內容如下 :

{% extends "jqueryeasyui.htm" %}
{% block style %}
    a {text-decoration:none;}
    a:hover {text-decoration:underline;background-color:yellow;}
    #west {width:150px;}
    #west-inner {border-top:0px;border-right:0px;border-bottom:0px;}
    .nav {padding:5px;}
    .tab {padding:10px;}
    #north {height:55px;overflow:hidden;}
    #north-table {width:100%;border-spacing:0px}
    #north-left {text-align:left;padding:5px;}
    #north-right {text-align:right;padding:5px;}
{% endblock%}
{% block body %}
  <div id="north" title="EasyUI-based CMS on GAE" data-options="region:'north',border:false,collapsible:true,tools:'#tools'">
    <form id="header-form" method="post">
      <table id="north-table">
        <tr>
          <td id="north-left" style="vertical-align:middle">
            您好! admin, 今天是 2016 年 01 月 12 日 星期二
          </td>
          <td id="north-right" style="vertical-align:middle">
            <span id="header_links">
              <a href="javascript:gohome()" target="_self" title="首頁">首頁</a>.
              <a href="javascript:logout()" target="_self" title="登出">登出</a>.      
            </span>
            <select id="theme_sel" name="theme" class="easyui-combobox" style="width:120px;height:18px" panelHeight="auto">
              <option value="default">主題布景</option>
              <option value="default" selected>default</option>
              <option value="gray">gray</option>
              <option value="black">black</option>
              <option value="bootstrap">bootstrap</option>
              <option value="metro">metro</option>
              <option value="metro-blue">metro-blue</option>
              <option value="metro-gray">metro-gray</option>
              <option value="metro-green">metro-green</option>
              <option value="metro-orange">metro-orange</option>
              <option value="metro-red">metro-red</option>
              <option value="ui-cupertino">ui-cupertino</option>
              <option value="ui-dark-hive">ui-dark-hive</option>
              <option value="ui-pepper-grinder">ui-pepper-grinder</option>
              <option value="ui-sunny">ui-sunny</option>
            </select>
          </td>
        </tr>
      </table>
    </form>
  </div>
  <div id="tools">
   <a href="javascript:logout()" class="icon-remove" title="登出"></a>
  </div>
  <div title="導覽" data-options="region:'west',border:true" id="west">
    <div class="easyui-accordion" id="west-inner">
      <div title="主功能" class="nav">
        .<a href="javascript:gohome()" target="_self" title="首頁">首頁</a><br>
        .<a href="javascript:logout()" target="_self" title="登出">登出</a><br>
      </div>
    </div>
  </div>
  <div data-options="region:'center',border:false,href:'/systabs_1'" id="center">
  </div>
  <script>
    //$(document.body).addClass("easyui-layout");
    $("body").attr("class", "easyui-layout");
    <!-- 系統的載入與登出函式 -->
    function gohome(){
      $(function(){
        var p=$("body").layout("panel","center");
        p.panel({href:"/systabs_1"});
        });
      }
    function logout(){
      $(function(){
        $.messager.confirm("確認","確定要登出系統嗎?",function(btn){
          if (btn) {window.location.href="/logout";}
          });
        });
      }
  </script>
{% endblock%}

測試 1 : http://jqueryeasyui.appspot.com/main_1 (下載原始碼(備用下載點)


在這個網頁模板中, 我們使用 jQuery 的 attr() 方法將 body 元素加上 "easyui-layout" 樣式, 這樣便能利用 div 元素來進行排版. 版面分割為 north, left, 以及 center 三個版面, 上方的 north 用來放置網站標題, 日期與問候語, 應用程式超連結, 以及主題切換器. 左方是導覽列, 中央則是內容區, 用來呈現系統設管理與應用程式. 因為只是要測試版面規劃, 所以標題, 歡迎詞, 日期, 主題布景等都是固定的.

另外中間版面的內容指定為 /systabs_1, 此路徑的處理類別如下 :

class systabs_1(webapp2.RequestHandler):
    def get(self):
        #check login session
        url="templates/systabs_1.htm"
        path=os.path.join(os.path.dirname(__file__), url)
        content=template.render(path,{})
        self.response.out.write(content)

很簡單就是渲染網頁模板 systabs_1.htm 如下, 是一個 EasyUI 頁籤面板元件, 包含兩個頁籤, 來源分別是 /home 與 /admin 路徑 :

<div id="sys_tabs" class="easyui-tabs" data-options="fit:'true'">
  <div class="tab" title="首頁" data-options="href:'/home',loadingMessage:'載入中 ... '"></div>
  <div class="tab" title="管理" data-options="href:'/admin',loadingMessage:'載入中 ... '"></div>
</div>

而 /home 與 /admin 路徑也是簡單地渲染 home.htm 與 admin.htm 兩個網頁, home.htm 在上一篇文章中已用過, 就是輸出 Welcome! 而已. 路徑 /admin 的處理類別如下 :

class admin(webapp2.RequestHandler):
    def get(self):
        url="templates/admin.htm"
        path=os.path.join(os.path.dirname(__file__), url)
        content=template.render(path,{})
        self.response.out.write(content)

而 admin.htm 則複製 home.htm, 只改變輸出為 Admin only! 而已 : 

{% extends "jqueryeasyui.htm" %}
{% block style %}
  body {font: 80% "Trebuchet MS", sans-serif; margin: 50px;}
{% endblock%}
{% block body %}
<p>Admin only!</p>
{% endblock%}

以上只是為整個 CMS 系統打個版面架構, 下面進行細部功能實現.

先處理主題佈景部分, 在版面右上方有一個用 combobox 做的主題佈景選擇器, 使用者可以隨時切換主題佈景, 而且每次切換會被記錄在資料庫裡, 作為下一次登入時的預設佈景. 這在 PHP 可以很簡單地利用 Session 來記住使用者目前的佈景設定, 參考 :

# 用 jQuery EasyUI 打造輕量級 CMS (五)

在 GAE 的 webapp2 框架也提供了 Sessions 模組可用來在伺服端記住使用者狀態 (Session 功能在早期 webapp 框架時代是不提供的, 但可以利用 Memcache API 來模擬, 參考上官林傑 "Google 應用服務引擎開發實戰" 10-2 節, P287). 參考 :

# Using SESSION with Google App Engine and Python 2.7
# Webapp2 Sessions in Google app engine
# How to handle sessions in Google app engine
# Webapp2 Sessions

參考上面四篇作法, 欲使用 sessions 模組, 需從 webapp2_extra 匯入模組 sessions, 我在 main.py 中加入這行 :

from webapp2_extras import sessions

然後新增一個 BaseHandler 類別 :

class BaseHandler(webapp2.RequestHandler):
    def dispatch(self):
        # Get a session store for this request.
        self.session_store=sessions.get_store(request=self.request)
        try:
            #Dispatch the request.
            webapp2.RequestHandler.dispatch(self)
        finally:
            #Save all sessions.
            self.session_store.save_sessions(self.response)
    @webapp2.cached_property
    def session(self):
        #Returns a session using the default cookie key.
        sess=self.session_store.get_session()
        #add some default values:
        if not sess.get("theme"):
            sess["theme"]="default"
        return sess

這裡我們呼叫 get_session() 方法取得 Session 實體集合, 然後回傳給呼叫 session() 方法的使用者. 不過在回傳前設定特定 Session 物件的預設值, 例如我想讓使用者透過下拉式選單隨時更換網站的主題佈景, 由於 HTTP 是無狀態協定, 如何讓網站伺服器記得我們目前的佈景設定呢? 這就得依賴伺服端的Session 連線物件來記錄了. 這裡利用 Session 物件實體的 get() 方法來判斷伺服器是否已有名為 theme 的 Session, 沒有的話就產生一個, 且其值設為 EasyUI 的預設佈景 "default". 任何需要設定初始值的連線物件都可以在這裡用 if 來設值.

除此之外, 使用 sessions 模組還必須設定 secret key, 所以我依照 Webapp2 Sessions 這篇所述, 在 main.py 中新增一個 config 物件, 設定 webapp2_extra.sessions 屬性值為一個含有 secret_key 屬性之物件 (其值可任意定, 我只是照抄), 然後將此 config 物件傳給 webapp2.WSGIApplication() 方法的 config 參數即可, 如下所示 :

config={}
config['webapp2_extras.sessions']={'secret_key':'my-super-secret-key'}
app = webapp2.WSGIApplication([
    ('/', MainHandler),
    ('/easyui_1', easyui_1),
    ('/easyui_2', easyui_2),
    ('/easyui_3', easyui_3),
    ('/easyui_4', easyui_4),
    ('/login_4', login_4),
    ........
    ('/get_visitors_6', get_visitors_6),
    ('/main_1', main_1),
    ('/login', login),
    ('/login_check', login_check)
], debug=True, config=config)

如果沒有設定 secret_key 的話, 會出現如下錯誤訊息 :

Exception: Missing configuration keys for 'webapp2_extras.sessions': ['secret_key'].

注意, 所有需要用到 Session 功能的類別都要繼承這個 BaseHandler 類別來處理連線狀態, 事實上也已經繼承了 webapp2.RequestHandler 類別, 也可以處理來自前端的要求.

搞定 Session 功能後就可以進行登入功能的改寫, 當使用者登入系統經過資料庫驗證為合法使用者之後, 就在伺服端產生 Session 物件來記錄登入狀態, 每個系統功能的 url 處理器在開頭的地方都檢驗是否存在連線物件, 有的話即許可進入, 否則全部導向登入畫面, 這是網站安全控管的必要措施.

但在此之前還須做一下資料庫配置. 首先, 為了方便產生主題佈景選單 (即讓被選的 selected), 我在 model.py 中新增 Themes 這個 Model 來記錄 EasyUI 內建的幾種佈景 :

class Themes(db.Model):
    theme=db.StringProperty()

theme=Themes(key_name="default",theme="default")
theme.put()
theme=Themes(key_name="gray",theme="gray")
theme.put()
theme=Themes(key_name="black",theme="black")
theme.put()
theme=Themes(key_name="bootstrap",theme="bootstrap")
theme.put()
theme=Themes(key_name="metro",theme="metro")
theme.put()
theme=Themes(key_name="metro-blue",theme="metro-blue")
theme.put()
theme=Themes(key_name="metro-gray",theme="metro-gray")
theme.put()
theme=Themes(key_name="metro-green",theme="metro-green")
theme.put()
theme=Themes(key_name="metro-orange",theme="metro-orange")
theme.put()
theme=Themes(key_name="metro-red",theme="metro-red")
theme.put()
theme=Themes(key_name="ui-cupertino",theme="ui-cupertino")
theme.put()
theme=Themes(key_name="ui-dark-hive",theme="ui-dark-hive")
theme.put()
theme=Themes(key_name="ui-pepper-grinder",theme="ui-pepper-grinder")
theme.put()
theme=Themes(key_name="ui-sunny",theme="ui-sunny")
theme.put()

注意, 這裡每一個資料實體 (entity, 即紀錄也) 都要設一個 key_name, 方便擷取紀錄時可以用 Model 的 get_by_key_name() 方法來直接取出資料, 雖然在 Themes 這個資料表用不到, 但設 key_name 才不會在每次載入 model.py 時重複產生資料實體, 導致同樣的實體一大堆.

其次, 為了記錄使用者個人的主題佈景, 以便下次登入時能顯示上一次登出前的佈景, 我修改了使用者資料表 Members, 增加 theme 與 is_admin 這兩個欄位, 每次當使用者更改其佈景時, 也會同時更新此欄位, 而 is_admin 則是用來標示使用者是否為管理員, 主要是控制可使用的功能, 例如 Tab 或按鈕等等 :

class Members(db.Model):
    account=db.StringProperty()
    password=db.StringProperty()
    theme=db.StringProperty()
    is_admin=db.BooleanProperty()

member=Members(key_name="admin",account="admin",password="aaa",
    theme="default",is_admin=True)
member.put()
member=Members(key_name="guest",account="guest",password="guest",
    theme="black",is_admin=False)
member.put()

這裡的 theme 欄位用來記錄個人的主題佈景, 登入成功後會將此值寫入名為 theme 的 Session 中,  以便在應用程式中套用. 而 is_admin 欄位用來標示此用戶是否為管理員, 此值也會寫入名為 is_admin 的 Session 中, 用來控制應用程式中只能讓管理員看的部分. 同樣地, 我們也要設 key_name, 此處以具有唯一性的 account 欄位值作為 key_name 之值.

另外, 我也新增了一個 Settings 資料表來記錄網站名稱, 網站預設主題佈景 (用在登入頁面), 網站狀態 (運轉中/維護中) 等 :

class Settings(db.Model):
    site_title=db.StringProperty()
    site_theme=db.StringProperty()
    site_state=db.StringProperty()
    site_created_time=db.DateTimeProperty(auto_now_add=True)

settings=Settings(key_name="settings",site_title="EasyUI-based CMS on GAE",
    site_theme="ui-sunny",site_state="")
settings.put()

其中, site_theme 用在登入對話框的, 讓管理者可以隨時更改預設門面, 而非寫死在程式裡. 但使用者登入後就會改成使用者個人佈景了.  此資料表只會有一個實體, 為了存取方便也為其設了一個名為 "setings" 的 key name.

經過這樣安排就可以開始寫登入頁面了, 此 login 路徑處理類別如下 :

class login(webapp2.RequestHandler):
    def get(self):
        s=m.Settings.get_by_key_name("settings")
        info={}
        info["site_title"]=s.site_title
        info["site_theme"]=s.site_theme
        url="templates/login.htm"
        path=os.path.join(os.path.dirname(__file__), url)
        content=template.render(path,{"info":info})
        self.response.out.write(content)

這裡首先用 get_by_key_name() 方法來取出名為 settings 的資料實體, 將其中的 site_title 與 site_theme 欄位存入名為 info 的字典物件中, 然後在渲染登入頁面模板 login.htm 時將 info 物件傳給它. 此 login.htm 登入頁面是從上一篇的測試 3 所使用的 easyui_5_1 類別複製過來修改的, 只是將 easyui_5_1 改為 login 而已, 當然所渲染的 login.htm 也是從 easyui_5_1.htm 複製過來修改為 login.htm 如下 :

{% extends "jqueryeasyui.htm" %}
{% block body %}
  <div id="login-dialog" class="easyui-dialog" title="{{info.site_title}} 系統登入" style="width:370px;height:230px;padding:10px" buttons="#login-buttons">
    <div style="margin:5px;border-bottom:1px solid #ccc;">
      <p id="msg">請輸入帳號密碼</p>
    </div>
    <form id="login-form" method="post" style="padding:10px 30px;">
      <div style="margin:5px">
        <label style="width:60px;display:inline-block;">帳號 : </label>
        <input id="account" name="account" type="text" class="easyui-textbox" required="true" data-options="iconCls:'icon-man',missingMessage:'此欄位為必填'"  style="width:200px">
      </div>
      <div style="margin:5px">
        <label style="width:60px;display:inline-block;">密碼 : </label>
        <input name="password" type="password" class="easyui-textbox" required="true" data-options="iconCls:'icon-lock',missingMessage:'此欄位為必填'" style="width:200px">
      </div>
    </form>
  </div>
  <div id="login-buttons" style="padding-right:15px;">
    <a href="#" id="cancel" class="easyui-linkbutton" iconCls="icon-cancel" style="width:90px">取消</a>
    <a href="#" id="login" class="easyui-linkbutton" iconCls="icon-ok" style="width:90px">登入</a>
  </div>
  <script type="text/javascript">
    $(document).ready(function(){
      var css="/static/easyui/themes/{{info.site_theme}}/easyui.css";
      $("#theme").attr("href", css);  //更改主題佈景
      $("#login-form").form({ //設定表單
        url:"/login_check",
        success:function(data){
          var data=eval('(' + data + ')');  //將 JSON 轉成物件
          if (data.result=="success") {window.location.href='/main_2';}
          else {$("#msg").text("帳號或密碼錯誤!");}
          }
        });
      $("#account").textbox('clear').textbox('textbox').focus();
      $("#login").bind("click",function(){
        $("#login-form").submit();  //提交表單進行驗證
        });
      $("#cancel").bind("click",function(){
        $("#login-form").form("reset");  //清除表單欄位
        });
      });
  </script>
{% endblock %}


這裡我們利用從 main.py 傳進來的 info 物件來設定登入對話框的標題, 以及透過 jQuery 的 attr() 方法修改頁面只修改了兩個跟 URL 有關的地方, 一是按下確定鈕後的驗證程式改為 login_check, 二是驗證通過後要導向的目的地路徑設為 main_2, 此路徑處理程式將渲染一個網頁模板 main_2.htm, 由上面測試 1 打好的網頁版面 main_1.htm 修改而來.

注意, 這裡我意外找到之前 EasyUI Dialog box 無法聚焦於其中的輸入框問題, 原來是要用 textbox() 方法取得輸入框元件後再呼叫 focus() 方法 :

$("#account").textbox('clear').textbox('textbox').focus();

參考 :

how can i focus the textbox

首先處理登入檢查程式 login_check, 也是紀錄連線狀態的關鍵程式, 我們在 main.py 中新增 login_check 路徑與其處理類別如下 :

class login_check(BaseHandler):
    def post(self):
        account=self.request.get("account", default_value="unknown")
        password=self.request.get("password", default_value="unknown")
        query=m.Members.gql("""WHERE account= :1
                               AND password= :2""",
                               account,password)
        result=query.get()
        if result is None:
            content='{"result":"failure","reason":"帳號或密碼錯誤"}'
        else:
            self.session['account']=result.account     #save session
            self.session['theme']=result.theme         #save session
            self.session['is_admin']=result.is_admin   #save session
            content='{"result":"success"}'          
        self.response.out.write(content)

事實上這個 login_check 是從上一篇的 login_5 類別複製過來修改的, 只是在 else 區塊中加入三個 session 變數的設定而已, 就是將使用者資料實體的 account, theme, 以及 is_admin 三個欄位值存到 session 中, 以利後續應用程式使用.

在 login.htm 中, 登入成功後 jQuery 會將頁面導向 /main_2 路徑, 其處理類別如下 :

class main_2(BaseHandler):
    def get(self):
        info={}  #for storing parameters
        #check login session
        account=self.session.get('account')
        if account: #already logged in
            info["account"]=account
            s=m.Settings.get_by_key_name("settings")
            info["site_title"]=s.site_title
            theme=self.session.get('theme')
            info["theme"]=theme
            today=datetime.date.today()
            week=[u"一",u"二",u"三",u"四",u"五",u"六",u"日"]
            info["greeting"]=u"您好! " + account + u", 今天是 " + \
                str(today.year) +  u" 年 " + str(today.month) + u" 月 " + \
                str(today.day) + u" 日 星期" + week[today.weekday()]
            theme_list=[]
            themes=m.Themes.all()
            #themes.order("theme")
            for t in themes:
                theme_list.append(t.theme)
            info["themes"]=theme_list
            url="templates/main_2.htm"
        else:  #not logged in
            s=m.Settings.get_by_key_name("settings")
            info["site_title"]=s.site_title
            info["site_theme"]=s.site_theme
            url="templates/login.htm"
        theme=self.session.get('theme')
        path=os.path.join(os.path.dirname(__file__), url)
        content=template.render(path,{'info':info})
        self.response.out.write(content)

此類別會依據 session 物件變數 account 判別用戶是否已登入, 是的話就渲染 main_2.htm 網頁模板, 否則渲染 login.htm 強制導向登入頁面. 若用戶已登入, 就將 account 存入 info 字典, 然後從 session 變數 theme 取得主題佈景存入 info 字典索引 theme 裡. 接著呼叫 datetime.date.today() 取得今日物件以製作歡迎詞, 存入字典索引 greeting 中. 最後是讀取 Themes 資料表, 利用串列儲存主題佈景名稱 (呼叫串列的 append 方法), 存入字典索引 themes 中.  如果用戶未登入, 就從資料表 settings 中取出 site_title 與 site_theme 傳給登入頁面.

這裡取得星期幾是利用 today 物件的 weekday() 方法, 它會傳回 0~6, 0 是星期一, 2~6 是星期二到星期日, 所以 week 串列要這樣安排 :

week=[u"一",u"二",u"三",u"四",u"五",u"六",u"日"]

參考 :

pyMOTW : datetime – Date/time value manipulation
python 获得日期是星期几
python datetime extract double digit month and day values

注意, 為了能正確顯示中文, 所有中文字串我都採用在前面加 u 來標示使用 unicode 的 utf-8 編碼, 同時所有檔案, 不論是 python 或 html 模板檔案, 都是以 utf-8 編碼存檔, 關於 GAE 上的中文問題, 參考下面這篇 :

解決方法: UnicodeDecodeError: 'ascii' codec can't decode byte 0xe4 in position 0: ordinal not in range(128)

網頁模板 main_2.htm 是從上面的 main_1.htm 修改而得 :

{% extends "jqueryeasyui.htm" %}
{% block style %}
    a {text-decoration:none;}
    a:hover {text-decoration:underline;background-color:yellow;}
    #west {width:150px;}
    #west-inner {border-top:0px;border-right:0px;border-bottom:0px;}
    .nav {padding:5px;}
    .tab {padding:10px;}
    #north {height:55px;overflow:hidden;}
    #north-table {width:100%;border-spacing:0px}
    #north-left {text-align:left;padding:5px;}
    #north-right {text-align:right;padding:5px;}
{% endblock%}
{% block body %}
  <div id="north" title="{{ info.site_title }}" data-options="region:'north',border:false,collapsible:true,tools:'#tools'">
    <form id="header-form" method="post">
      <table id="north-table">
        <tr>
          <td id="north-left" style="vertical-align:middle">
            {{ info.greeting }}
          </td>
          <td id="north-right" style="vertical-align:middle">
            <span id="header_links">
              <a href="javascript:gohome()" target="_self" title="首頁">首頁</a>.
              <a href="javascript:logout()" target="_self" title="登出">登出</a>.      
            </span>
            <select id="theme_sel" name="theme" class="easyui-combobox" style="width:120px;height:18px" panelHeight="auto">
              <option value="default">主題布景</option>
{% for t in info.themes %}
              <option value="{{t}}"{% ifequal t info.theme %} selected{% endifequal %}>{{t}}</option>
{% endfor %}
            </select>
          </td>
        </tr>
      </table>
    </form>
  </div>
  <div id="tools">
   <a href="javascript:logout()" class="icon-remove" title="登出"></a>
  </div>
  <div title="導覽" data-options="region:'west',border:true" id="west">
    <div class="easyui-accordion" id="west-inner">
      <div title="主功能" class="nav">
        .<a href="javascript:gohome()" target="_self" title="首頁">首頁</a><br>
        .<a href="javascript:logout()" target="_self" title="登出">登出</a><br>
      </div>
    </div>
  </div>
  <div data-options="region:'center',border:false,href:'/systabs'" id="center">
  </div>
  <script>
    $("body").attr("class", "easyui-layout");
    $(document).ready(function(){    
      //var css="http://www.jeasyui.com/easyui/themes/{{ info.theme }}/easyui.css";
      var css="/static/easyui/themes/{{ info.theme }}/easyui.css";
      $("#theme").attr("href", css);
      $("#theme_sel").combobox({
        onSelect:function(rec){
          //var css="http://www.jeasyui.com/easyui/themes/" + rec.value + "/easyui.css";
          var css="/static/easyui/themes/" + rec.value + "/easyui.css";
          $("#theme").attr("href", css);
          $.get("/change_theme",{theme:rec.value});
          }
        });
      });
    <!-- 系統的載入與登出函式 -->
    function gohome(){
      $(function(){
        var p=$("body").layout("panel","center");
        p.panel({href:"/systabs_2"});
        });
      }
    function logout(){
      $(function(){
        $.messager.confirm("確認","確定要登出系統嗎?",function(btn){
          if (btn) {window.location.href="/logout";}
          });
        });
      }
  </script>
{% endblock%}

這裡傳進來的 info.site_title 輸出在版面北方 div 的 title 中, 而 info.themes 則用來以 for 迴圈產生 combobox 元件的 option 選項, 當其值與使用者的個人主題佈景相同時會加上 selected 屬性, 讓下拉式選單顯示此選項為被選擇狀態. 至於 info.theme 則用來將從 jqueryeasyui.htm 繼承過來的預設主題佈景 default 改為使用者自己的佈景.

當使用者用下拉式選單更改佈景時,  會先用 jQuery 的 attr() 方法更改 Link 元素中的 easyui.css 檔的路徑, 這會立即改變頁面外觀, 其次會以 Ajax 的 get 方法向後端 /change_theme 提出要求, 並傳出 theme 參數, 因為 Combobox 元件裡的 select 元素的 name 為 theme 之故. 其路徑處理類別如下 :

class change_theme(BaseHandler):
    def get(self):
        theme=self.request.get("theme")  #get selected theme
        self.session['theme']=theme  #update user session
        account=self.session.get('account')
        member=m.Members.get_by_key_name(account)  #retrieve entity
        member.theme=theme
        member.put()  #update user theme in datastre

這裡因為要用到 session 物件, 因此要繼承 BaseHandler, 利用 self.requst.get() 方法取得前端主題佈景選擇器傳來的 theme 參數後, 先更新 session 變數 theme, 再到資料儲存的 Members 資料表取得此使用者之資料實體, 更改其 theme 欄位為前端傳來之值即可.

接下來處理 main_2.htm 的中間面板部分. 在上面測試 1 中, 中間面板只是渲染一個兩個固定頁籤網頁, 但這裡要進一步實現動態頁籤輸出功能, 意即透過資料儲存來管理頁籤. 我在 model.py 中新增 Systabs 資料表如下, 同時建立兩個頁籤實體 :

class Systabs(db.Model):
    tab_name=db.StringProperty()
    tab_label=db.StringProperty()
    tab_link=db.StringProperty()
    tab_tip=db.StringProperty()
    tab_order=db.IntegerProperty()
    tab_admin=db.BooleanProperty()

systab=Systabs(key_name="home",tab_name="home",tab_label=u"首頁",
    tab_link="/home",tab_order=0,tab_admin=False)
systab.put()
systab=Systabs(key_name="admin",tab_name="admin",tab_label=u"管理",
    tab_link="/admin",tab_order=1,tab_admin=True)
systab.put()

此處的重要欄位是要顯示在頁籤上的 tab_label, 其處理路徑 tab_link, 頁籤顯示順序的 tab_order, 以及是否為管理員使用頁籤的 tab_admin, 而 tab_name 欄位我現在也不太確定需不需要, 我只是先照搬 PHP 版的過來而已.

路徑 /systabs_2 的處理類別如下 :

class systabs_2(BaseHandler):
    def get(self):
        systabs=m.Systabs.all()
        systabs.order("tab_order")  #sort by tab_order
        tabs=[]  #for storing tab objects
        is_admin=self.session.get('is_admin')  #True/False
        for t in systabs:
            tab={}
            tab["tab_label"]=t.tab_label
            tab["tab_link"]=t.tab_link
            if t.tab_admin:  #this tab is for admin only
                if is_admin: #current user is admin
                    tabs.append(tab)
            else:  #this tab is for registered users
                tabs.append(tab)
        url="templates/systabs_2.htm"
        path=os.path.join(os.path.dirname(__file__), url)
        content=template.render(path,{"tabs":tabs})
        self.response.out.write(content)

這裡是先讀取 Systabs 資料表中全部實體, 以 tab_order 欄位排序後利用迴圈拜訪 Query 物件, 然後把每一個頁籤實體的 tab_label 與 tab_link 存入一個 tab 字典物件, 如果是管理者頁籤且使用者是管理者, 就把 tab 物件用 append() 方法存入 tabs 串列, 如果不是管理者頁籤, 都一律會存入 tabs 串列, 最後把 tabs 串列作為變數在渲染 systabs_2.htm 網頁時傳給它. 此 systabs_2.htm 是由 systabs_1.htm 複製修改而來, 內容如下 :

<div id="sys_tabs" class="easyui-tabs" data-options="fit:'true'">
{% for t in tabs %}
  <div class="tab" title="{{t.tab_label}}" data-options="href:'{{t.tab_link}}',loadingMessage:'載入中 ... '"></div>
{% endfor %}
</div>

這裡用 for 迴圈來拜訪所傳入的 tabs 變數, 然後將 tab_label 填入 div 元素的title 屬性, 將 tab_link 填入 href 屬性中即可.

這個 main_2.htm 中還有一個登出功能尚未實現, 當按登出連結或按鈕時會向後端 /logout 路徑提出要求, 其路徑處理類別如下 :

class logout(BaseHandler):
    def get(self):
        #check login session
        self.session.clear()
        self.redirect("/login")

它會先呼叫 session 物件的 clear() 方法將此連線相關的 session 變數全部清除, 然後將頁面重導向至 /login 路徑, 即登入頁面.


以上就是這次測試的全紀錄, 實際測試網頁及 zip 原始碼在此 (裡面包含了自備的 Easyui 1.4 版) :

測試 2 : http://jqueryeasyui.appspot.com/main_2 (下載原始碼) (備用下載點)


如果是以非管理者的 guest 帳號登入, 則沒有 "管理" 頁籤, 可見 Members 裡的 is_admin 欄位達成權限管控目的 :


自從 2010 年開始接觸到 GAE 以後, 我就想能否在 GAE 上面寫個 CMS 呢? 現在終於實現了! 從 1/12 開始動手, 中間被其他事務打斷, 斷斷續續來回測試到 OK 超過半個月. 雖然目前還只是陽春的架構, 但至少透過實作證實了自己的猜想, 還有其他像檔案上傳, 留言板, 網站管理, 加掛應用程式等 CMS 主要功能待實現, 以後再一步步加上去.

這次的測試也發現了一些新作法, 例如 site_theme 欄位就是 PHP 版所沒有的, 其次是主題佈景下拉式選單的高度 pannelHeight 可以設為 'auto', 這樣底下就不會留白了, 這些在下次修改 PHP 版本時可以考慮進去. 我不一定要把 PHP 版移植到 GAE 上, 畢竟我最熟悉的還是 PHP, 我的 Python 程度還在打游擊階段, 邊學邊賣, 困而習之而已, 我知道 Python 很強, 需要有時間去系統化的學習才能掌握, 學 Python 不光是為了 GAE 而已, Raspberry Pi 也用得到.

參考資料 :

update an entity in datastore app engine
Multiple datastore entities with the same ID!
# Google App Engine 初學(7) – Db Data Store 操作 (GQL)
# Khai's personal knowledge vault

這次測試也搜尋到幾個不錯的 Python 的學習資料如下 :

Learn Python the hard way
Python Module of the Week
# Eric Park 的 Python 筆記


沒有留言 :