2016年2月2日 星期二

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

完成 EasyUI CMS on GAE Part 1 測試後, 繼續來給它添柴火. 首先我想把之前寫的訪客紀錄器整合進來, 參考 :

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

我先在 model.py 中給 systabs 增加一個只有管理者能看到的系統頁籤實體 :

systab=Systabs(key_name="list_visitors",tab_name="list_visitors",
    tab_label=u"到訪者",tab_link="/list_visitors",tab_order=2,tab_admin=True)
systab.put()

為了與前一篇的測試程式區隔, 我在 main.py 中增加新的路徑 main_3, 其處理類別則從前一篇的 main_2 複製過來, 修改渲染對象為 main_3.htm, 並在前面加上紀錄訪客資訊的程式碼, 如下藍色的部分所示 :

class main_3(BaseHandler):
    def get(self):
        #save visitor info
        ip=self.request.remote_addr
        #user_agent=os.environ.get("HTTP_USER_AGENT")
        user_agent=self.request.headers.get("User-Agent")
        visitor=m.Visitors()
        visitor.ip=ip
        visitor.visit_time=datetime.datetime.now() + timedelta(hours=+8)
        visitor.user_agent=user_agent
        visitor.ip_list=list(ip)
        visitor.user_agent_list=list(user_agent)
        visitor.put()
        #check login session
        info={}  #for storing parameters
        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_3.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)

所渲染之網頁模板 main_3.htm 也是從 main_2.htm 複製而來, 只是把其中的 systabs_2 改為 systabs_3 而已 :

{% 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_3'" 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_3"});
        });
      }
    function logout(){
      $(function(){
        $.messager.confirm("確認","確定要登出系統嗎?",function(btn){
          if (btn) {window.location.href="/logout";}
          });
        });
      }
  </script>
{% endblock%}

系統頁籤路徑 systabs_3 的處理類別也是從 systabs_2 複製而得, 僅修改所渲染的網頁為 systabs_3.htm, 如下所示 :

class systabs_3(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_3.htm"
        path=os.path.join(os.path.dirname(__file__), url)
        content=template.render(path,{"tabs":tabs})
        self.response.out.write(content)

所渲染的系統頁籤網頁 systabs_3.htm 則從 systabs_2.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>

這樣顯示的部分就搞定了, 接下來處理新增的訪客頁籤路徑 /list_visitors, 其處理類別如下, 就是渲染 list_visitors.htm 網頁 :

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

而網頁 list_visitors.htm 則是從下面這篇文章的測試 6 的 list_visitors_6.htm 複製過來修改的 :

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

{% extends "jqueryeasyui.htm" %}
{% block style %}
  body {font: 80% "Trebuchet MS", sans-serif; margin: 50px;}
{% endblock%}
{% block body %}
  <table id="visitors" title="訪客列表" style="width:auto;" data-options="tools:'#visitors_tools',toolbar:'#visitors_search_bar'"></table>
  <div id="visitors_tools">
    <a href="#" id="remove_visitor" class="icon-remove" title="刪除"></a>
    <a href="#" id="reload_visitors" class="icon-reload" title="重新載入"></a>
  </div>
  <div id="visitors_search_bar" style="text-align:right;padding:2px;">
    <select id="visitors_search_field" class="easyui-combobox" data-options="panelHeight:'auto'">
      <option value="ip">IP 位址</option>
      <option value="visit_time">到訪時間</option>
    </select>
    <input id="visitors_search_what" class="easyui-textbox">
  </div>
  <script language="javascript">
    $(document).ready(function(){
      $('#visitors').datagrid({
        url:'/get_visitors',
        method:"post",
        columns:[[
          {field:'ip',title:'IP 位址',sortable:true},
          {field:'visit_time',title:'到訪時間',sortable:true},
          {field:'user_agent',title:'瀏覽器',sortable:true}
          ]],
        singleSelect:true,
        fitColumns:true,
        collapsible:true,
        rownumbers:true,
        pagination:true,
        collapsible:false,
        pageSize:10
        });
      $("#visitors_search_what").textbox({
        icons:[{
          iconCls:"icon-search",
          handler:function(e){
            $('#visitors').datagrid("load",{
              search_field:$("#visitors_search_field").combobox("getValue"),
              search_what:$("#visitors_search_what").textbox("getValue")
              });          
            }
          }]
        });
      $("#reload_visitors").bind("click",function(){
        $("#visitors").datagrid("load");
        });
      $("#remove_visitor").bind("click",function(){
        var row=$("#visitors").datagrid("getSelected");
        if (row) {
          $.messager.confirm("確認","確定要刪除這筆到訪紀錄嗎?",function(btn){
            if (btn){
              var params={ip:row.ip,visit_time:row.visit_time};
              var callback=function(data){
                if (data.status==="success"){
                  $("#visitors").datagrid("reload");
                  }
                else {$.messager.alert("訊息","刪除到訪紀錄失敗!","error");}            
                };                
              $.post("/remove_visitor",params,callback,"json");
              }
            })
          }
        else {$.messager.alert("訊息","請點選要刪除的資料!")}
        });
      });
  </script>
{% endblock%}

這裡用 Ajax 以 post 方法向後端 /get_visitors 取得 datagrid 所需的 json 資料; 點選任一列後按右上角刪除鈕則會用 Ajax 的 post 向後端 /remove_visitor 要求刪除資料. 上面黃色部分是 PHP 版所無, 我覺得加上去會比較好, 下次要改 PHP 版時要加上去.

路徑 get_visitors 的處理類別是從上述舊作的 get_visitors_6 複製過來的, 邏輯相同, 內容完全不用改, 如下所示 :

class get_visitors(webapp2.RequestHandler):
    def post(self):
        page=self.request.get("page")
        rows=self.request.get("rows")
        sort=self.request.get("sort")
        order=self.request.get("order")
        search_field=self.request.get("search_field")
        search_what=self.request.get("search_what")
        if len(page):
            page=int(page)
        else:
            page=1
        if len(rows):
            rows=int(rows)
        else:
            rows=10
        if not len(sort):
            sort="visit_time"
        if not len(order):
            order="desc"
        if len(search_field):
            if search_field=="ip_list":
                query=m.Visitors.gql("WHERE ip_list = :1", list(search_what))
            elif search_field=="user_agent_list":
                query=m.Visitors.gql("WHERE user_agent_list = :1", list(search_what))
            else:
                query=m.Visitors.all()
                query.filter(search_field + " >= ", search_what)
                query.filter(search_field + " < ", search_what + u'\ufffd') 
        else:
            query=m.Visitors.gql("ORDER BY %s %s" % (sort, order))
        visitors=query.fetch(rows, (page-1)*rows)
        rows=[]
        for v in visitors:
            visit_time=v.visit_time.strftime("%Y-%m-%d %H:%M:%S")
            visitor={"ip":v.ip,
                     "visit_time":visit_time,
                     "user_agent":v.user_agent}
            rows.append(visitor)
        count=query.count()
        obj={"total":count,"rows":rows}
        self.response.headers["Content-Type"]="application/json"
        self.response.out.write(json.dumps(obj))

而刪除資料的路徑 /remove_visitor 處理類別則是新寫的, 因為之前從沒試過刪除 Datastore 中的資料, 如下所示 :

class remove_visitor(webapp2.RequestHandler):
    def post(self):
        ip=self.request.get("ip")
        visit_time=self.request.get("visit_time")
        #dtobj=datetime.datetime.strptime(visit_time,"%Y-%m-%d %H:%M:%S")
        query=m.Visitors.all()
        query.filter("ip = ",ip)
        #query.filter("visit_time >= ",dtobj)
        visitor=query.fetch(1,0)
        db.delete(visitor)
        content='{"status":"success"}'            
        self.response.out.write(content)

由於 Visitors 資料表的各欄位沒有一個可作為 key, 所以我就沒有設 key name, 這造成要刪除時無法精準鎖定要刪除的資料實體. 雖然 list_visitors.htm 網頁會傳出 ip 與 visit_time 這兩個參數, 但是其中的 visit_time 值是為了顯示方便而格式化的值 (只到秒), 與存在資料庫中的實體的 visit_time 欄位值並不同, 我參考下列這篇將 visit_time 轉為 datetime 物件實體, 用 >= 去過濾, 但卻出現 Server Internal Error! 所以只好僅過濾 ip 欄位, 並用 fetch(1,0) 刪除符合該 ip 的第一筆 (不是時間最早的, 而是搜尋到的第一筆).

string to datetime with fractional seconds, on Google App Engine

實際測試範例與 zip 原始檔如下 : 

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


現在只是測試, 以後其實可以考慮在 Visitors 設 key_name, 以 IP 位址加上格式化時間當 key, 這樣刪除時就可以比較精確了. 另外, 日期搜尋似乎有問題, 不是很重要, 先擱著, 有空再 debug.


沒有留言 :