顯示具有 GAE 標籤的文章。 顯示所有文章
顯示具有 GAE 標籤的文章。 顯示所有文章

2019年11月9日 星期六

GCP (GAE) 學習筆記索引

最近因為要為我的 Fintech 專案尋找運算主機, 而雲端運算環境目前已非常成熟, 除了 AWS 與 Azure 之外, 對十年前使用的 GAE (現在已發展為 GCP) 也重燃興趣, 打算開始來重新學習 Google 雲端服務, 因此找出之前的 GAE 舊文章做成索引方便查考 (有些可能過時了).

2009 ~ 2016 的 GAE 部分 :

# Google應用服務引擎
# 如何申請 GAE 服務引擎帳戶
# GAE 的 Cron Job
# 下載備份 Google App Engine 應用程式檔案的方法
# 利用 GAE 與 Gmail 傳送電子郵件
# GAE 的 urlfetch 超時問題
# 如何讓網頁不會被 cache
# GAE 的時區問題
# 停止 GAE 的 cron jobs 的方法
# 從 GAE 的 log 觀察伺服器回應是否正常
# 如何在 GAE 上佈署 jQuery 與 ExtJS 專案
# 如何在 GAE 中處理 HTTP 要求
# GAE SDK Launcher 因 UnicodeDecodeError 無法運行問題
# GAE 的資料儲存 (Datastore)
# GAE 後台管理指令集
# 無法登入 GAE 問題
# GAE 出現 Server Error 問題
# 如何在 GAE 佈署 jQuery EasyUI 專案 (一)
# 如何在 GAE 上佈署 jQuery EasyUI 專案 (二) : CDN 資源
# 如何在 GAE 上佈署 jQuery EasyUI 專案 (三) : 會員登入
# 如何在 GAE 上佈署 jQuery EasyUI 專案 (四) : Google 登入
# 如何在 GAE 上佈署 jQuery EasyUI 專案 (五) : 紀錄到訪者
# 如何在 GAE 上佈署 jQuery EasyUI 專案 (六) : EasyUI CMS on GAE 之 1
# 如何在 GAE 上佈署 jQuery EasyUI 專案 (七) : EasyUI CMS on GAE 之 2
# 如何在 GAE 上佈署 jQuery EasyUI 專案 (八) : EasyUI CMS on GAE 之 3

2016年11月25日 星期五

下載備份 Google App Engine 應用程式檔案的新方式

之前我在 Hostinger 上申請的免費網站由於進行了大量運算被認為濫用免費資源而遭到停權 (我哪知道免費用戶的 CPU 使用量是多少啊), 結果促使我在前天向 Hostinger 購買了 Premium 虛擬主機服務. 但是我發現原先的免費帳號還是不斷地被之前設定的 GAE cron jobs 進行 query, 因此在寫信向 Hostinger 解釋之前必須先停止 GAE 上的 cron jobs 才行.

我參考了之前的文章去下載 GAE 應用程式以便檢查看看到底是哪一個應用程式在 trigger 我的 Hostinger 免費主機, 因為太久忘記了, 所以就從最接近的 appfog-twstockbot 開始查起 :

# 下載備份 Google App Engine 應用程式檔案的方法

但是照原先的方法下載卻發現不像以前那樣會在 DOS 命令列中直接詢問 Google 帳號密碼, 而是顯示如下訊息 :

Microsoft Windows [版本 10.0.14393]
(c) 2016 Microsoft Corporation. 著作權所有,並保留一切權利。

E:\>appcfg.py download_app -A appfog-twstockbot -V 1 E:\gae\appfog-twstockbot
09:35 PM Host: appengine.google.com
09:35 PM Fetching file list...
Your browser has been opened to visit:

    https://accounts.google.com/o/oauth2/auth?scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fappengine.admin+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcloud-platform+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.email&redirect_uri=http%3A%2F%2Flocalhost%3A8080%2F&response_type=code&client_id=550516889913.apps.googleusercontent.com&access_type=offline

If your browser is on a different machine then exit and re-run this
application with the command-line parameter

  --noauth_local_webserver

這時 DOS 就僵在那邊無法動彈 (其實就是在等待我們使用瀏覽器進行 Google 帳號認證), 同時冒出一個視窗詢問要用哪一個瀏覽器認證, 選好後就會開啟瀏覽器連線到 Google 進行認證, 我的 Google 帳號採用兩階段認證, 因此必須用登記的手機接收認證碼, 驗證成功後會顯示要求授權存取 GAE 與 Google Cloud 頁面 :


按允許後結束認證, DOS 畫面即回應開始下載 :

Authentication successful. (認證通過)
09:36 PM Fetching files...
09:36 PM [1/10] main.py (這個檔案就是我要檢查的 cron table 觸發對象)
09:36 PM [2/10] extjs.htm
09:36 PM [3/10] jquery.htm
09:36 PM [4/10] index.yaml
09:36 PM [5/10] static/dataTables.zh-tw.txt
09:36 PM [6/10] html5.htm
09:36 PM [7/10] app.yaml
09:36 PM [8/10] cron.yaml (這是 cron table)
09:36 PM [9/10] default.htm
09:36 PM [10/10] favicon.ico

打開 main.py (此為 MTV 架構中的 View, 或 MCV 架構中的 Controller, 負責 routing) 檢查, 果然就是去觸發我在 Hostinger 申請的免費的 16mb.com 網址, 抓到了. 以前如果佈署完應用程式就馬上做紀錄的話, 現在也不會這麼費事去調查.

接著檢查其中的 cron.yaml, 其內容就是 cron table :

cron:
- description: fetch_twse_daily_close
  url: /fetch_twse_daily_close
  schedule: every day 16:00
  timezone: Asia/Taipei
- description: fetch_twse_daily_institutes
  url: /fetch_twse_daily_institutes
  schedule: every day 16:02
  timezone: Asia/Taipei
- description: technical_analysis
  url: /technical_analysis
  schedule: every day 16:04
  timezone: Asia/Taipei
- description: create_daily_report
  url: /create_daily_report
  schedule: every day 16:08
  timezone: Asia/Taipei
- description: fetch_daily_report
  url: /fetch_daily_report
  schedule: every day 16:10
  timezone: Asia/Taipei
- description: fetch_twse_daily_debit
  url: /fetch_twse_daily_debit
  schedule: every day 21:00
  timezone: Asia/Taipei
- description: fetch_2330_tw_basic
  url: /fetch_2330_tw_basic
  schedule: every 11 minutes
  timezone: Asia/Taipei
- description: fetch_2330_tw_dividend
  url: /fetch_2330_tw_dividend
  schedule: every 22 minutes
  timezone: Asia/Taipei
- description: fetch_2330_tw_analysis
  url: /fetch_2330_tw_analysis
  schedule: every 12 minutes
  timezone: Asia/Taipei
- description: fetch_2330_tw_balance
  url: /fetch_2330_tw_balance
  schedule: every 13 minutes
  timezone: Asia/Taipei
- description: fetch_2330_tw_cash
  url: /fetch_2330_tw_cash
  schedule: every 14 minutes
  timezone: Asia/Taipei
- description: fetch_2330_tw_income
  url: /fetch_2330_tw_income
  schedule: every 15 minutes
  timezone: Asia/Taipei
- description: fetch_2330_tw_profit
  url: /fetch_2330_tw_profit
  schedule: every 16 minutes
  timezone: Asia/Taipei
- description: fetch_2330_tw_owner
  url: /fetch_2330_tw_owner
  schedule: every 21 minutes
  timezone: Asia/Taipei
- description: fetch_yahoo_company
  url: /fetch_yahoo_company
  schedule: every 60 minutes
  timezone: Asia/Taipei
- description: fetch_yahoo_dealing
  url: /fetch_yahoo_dealing
  schedule: every 9 minutes
  timezone: Asia/Taipei
- description: fetch_yahoo_credit
  url: /fetch_yahoo_credit
  schedule: every 10 minutes
  timezone: Asia/Taipei
- description: fetch_yahoo_major
  url: /fetch_yahoo_major
  schedule: every 10 minutes
  timezone: Asia/Taipei
- description: fetch_yahoo_earning
  url: /fetch_yahoo_earning
  schedule: every 20 minutes
  timezone: Asia/Taipei

把第二行以下全部刪除, 只留第一行的 cron: 然後更新 cron table 即可終止 cron jobs 了. 參考 :

# 停止 GAE 的 cron jobs 的方法

接下來下達更新 cron 的指令 :

E:\>cd gae

E:\GAE>appcfg.py update_cron appfog-twstockbot
11:14 PM Host: appengine.google.com
11:14 PM Uploading cron entries.

E:\GAE>

註 : 加上 --oauth2 透過瀏覽器認證 (新版的 GAE SDK 已預設不須加, 較舊的則需要)

E:\GAE>appcfg.py --oauth2 update_cron appfog-twstockbot


注意, 不論是下載/上傳應用程式, 或者更新 cron 必須在應用程式 (此處為 appfog-twstockbot) 的上一層目錄 (此處為 GAE) 下執行, 參考 :

# GAE 的 Cron Job

所以上面下載應用程式應該在 E:\GAE 下比較一致 :

E:\cd GAE
E:\GAE>appcfg.py download_app -A appfog-twstockbot -V 1 appfog-twstockbot

最後補充一點, 如果不要用瀏覽器認證, 要像以前那樣直接在 DOS 輸入帳號密碼, 只要加上一些參數即可 :

appcfg.py download_app -A appfog-twstockbot -V 1 E:\gae\appfog-twstockbot --noauth_local_webserver

不過因為我的 Google 已經採用兩階段認證, 因此不能輸入 Google 密碼, 而是要輸入機器認證專用密碼, 我覺得還是用目前以瀏覽器進行認證較方便.

2016-12-26 補充 :

昨晚停掉 GAE 上的 appfog-twstockbot 應用的 cron 後, 果然就靜下來了 :


這樣就可以寫信給 Hostinger 說我的 16mb.com 免費主機只是做實驗用途, 不會再進行大量運算了, 希望他們可以恢復運作. 其實既然已經購買了 Premium 服務, 我是不需要這個免費主機, 但看到帳號底下有個 abuse 字樣總是不舒服, 而且無效的 cron 不斷運作也是耗損能源, 一切無效的動作都必須停止.

# https://cloud.google.com/appengine/docs/python/download

2016-11-27 補充 :

今天在鄉下老家電腦上用較舊版的 GAE SDK 照上面程序下載, 發現它還是跟以前一樣在 DOS 中詢問 Email 與密碼, 因為我的 Google 帳號已申請兩階段認證, 所以這密碼可不是 Google 帳號的密碼, 而是在 Google 帳號設定中授權 GAE 存取的另一組密碼, 我之前有特別記下來, 但是輸入後卻無法通過認證, 真是傷腦筋, 還要回去找之前所寫的紀錄, 很浪費時間. 我直接在 Google 搜尋, 馬上就找到結果了, 參考這篇 :

# 使用Google App Engine上傳程式, 一直要求輸入帳號密碼, 如何解決?

原來只要加上 --oauth2 就會透過瀏覽器進行 Google 帳號認證了, 例如 :

appcfg.py --oauth2 download_app -A appfog-twstockbot -V 1 E:\GAE\appfog-twstockbot



2016年3月25日 星期五

Appfog 主機升版為 v2 後 twstockbot 改版問題

前陣子 Appfog 通知要升版為 v2, 我以為它要藉此把早期加入的免費會員掃地出門, 所以就趕緊下載程式與資料庫備份, 打算轉移陣地. 昨天連線網站, 出現如下無法連線畫面, 更加肯定真的不能用了 :

# Appfog v1 退場了


所以就著手把相對應的 GAE 應用服務修改為去 trigger 英國 Hostinger 主機. 但回頭想說難道原來的Appfog 登入網頁真的進不去了嗎? 試試居然還可以, 但進去後發現兩個應用程式狀態為 stopped, 我猜它只是在升版時把全部應用程式都關閉, 升版後卻沒自動幫我啟動服務而已, 手動開啟後 cron jobs 就恢復運作了 :

# https://console.appfog.com/


真是太好了! 這樣我就不用另闢蹊徑, 可以繼續使用 Appfog 的穩定服務啦! 從上面的 cron_log 紀錄可知 Appfog 是在 3/22 日凌晨快兩點時關閉了我的應用程式.

不過前陣子我的 EasyuiCMS 架站機有改版, 除了全部改用 mysqli 函式庫 (因為 Hostinger 已不支援 mysql 函式庫), 也修改了訪客列表, 增加顯示訪客來自哪一個國家, 這部分需要在手動調整好資料表後再更新程式. 要修改的部分參考 :

# 從 IP 查來源國家 (二)
# EasyuiCMS 改版為 v2 (mysqli)

依照步驟下載最新的 ip2nation.zip 檔解壓縮後得到 ip2nation.sql, 然後進入 phpmyadmin 後台的 import 上傳此 sql 檔, 結果出現 "No data was received to import" 錯誤 :


仔細看原來是伺服器預設最多只能上傳 2014KB 檔案, 而解壓後的 ip2nation.sql 為 3883KB, 已經超過了. 解決辦法是上傳壓縮檔, 但檔名必須符合其規範, 亦即要以 .sql.zip 結尾. 所以將下載來的 ip2nation.zip 改為 ip2nation.sql.zip (351KB) 再上傳就可以了 :


左方可見已建立 ip2nation 與 ip2nationCountries 資料表了.

接下來是在 phpmyadmin 的建立 nation 這張資料表來達成中文化, 從下列網址下載 nation.sql 檔 :

# 下載國名簡碼與中文國名對照表 nation.sql

其內容如下 :

CREATE TABLE IF NOT EXISTS `nation` (
  `code` varchar(4) COLLATE utf8_unicode_ci NOT NULL,
  `name` varchar(255) COLLATE utf8_unicode_ci DEFAULT NULL,
  PRIMARY KEY (`code`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;

INSERT INTO `nation` (`code`, `name`) VALUES
('ad', '安道爾'),
('ae', '阿拉伯聯合大公國'),
('af', '阿富汗'),
('ag', '安地卡'),
('ai', '安圭拉島'),
.....

('bl', '聖巴泰勒米'),
('mf', '法屬聖馬丁'),
('sx', '荷屬聖馬丁'),
('ss', '南蘇丹'),
('um', '美國本土外小島嶼');

此檔案僅 7KB 而已, 不須壓縮, 直接 import 即可.

建立了這三張表單就可以更新應用程式了.

開啟 command 視窗, 切換到 twstockbot 目錄, 下這兩個指令就可更新檔案了 :

af login

af update twstockbot

但是上傳應用程式後瀏覽網頁卻顯示系統安裝畫面, 我原以為是 db.php 弄錯了別的主機, 更正之後還是一樣, 後來我試著改回匯入 mysql 函式庫後就都恢復正常, 原來 Appfog 不支援 mysqli! 看來需要維持兩個版本的 EasyuiCMS 了, 如果應用服務部署在 Hostinger 就要用 mysqli 版本.


2016年3月4日 星期五

EasyuiCMS on GAE 新增應用程式的方法

完成 EasyuiCMS on GAE 基本架構之後, 感覺似乎離目標只剩一步之遙, 所以早上就趁著去上一個跟工作幾乎無關的無聊課 (防毒) 時, 把最後一塊拼圖補上, 那就是如何在此架站平台上新增應用服務呢?

在 PHP 版時只要單獨寫好應用程式與其安裝檔兩個檔案, 然後利用檔案上傳就可以自動處理應用程式安裝的所有工作, 安裝後馬上可用. 在 GAE 因為雲端平台架構的關係, 可能比較難做到這樣, 每次新增一個服務必須修改現有檔案並新增相關檔案. 以下便以新增一個工作日誌服務 JLOG 為例, 說明其增修程序.

首先修改資料儲存檔 model.py, 新增一個 Jlogtabs 頁籤的資料模型, 直接從系統的 Hometabs 複製過來改名稱即可 :

class Jlogtabs(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()

並建立 jlog_home, jlog_knowledge, jlog_admin 三個頁籤的資料實體 :

jlogtab=Jlogtabs(key_name="jlog_home",tab_name="jlog_home",tab_label=u"工作日誌",tab_link="/jlog_home",tab_order=0,tab_admin=False)
jlogtab.put()
jlogtab=Jlogtabs(key_name="jlog_knowledge",tab_name="jlog_knowledge",tab_label=u"知識庫",tab_link="/jlog_knowledge",tab_order=98,tab_admin=False)
jlogtab.put()
jlogtab=Jlogtabs(key_name="jlog_admin",tab_name="jlog_admin",
    tab_label=u"管理",tab_link="/jlog_admin",tab_order=99,tab_admin=True)
jlogtab.put()

然後新增一個標頭連結 JLOG 就完成資料庫部分的調整作業了 :

headerlink=Headerlinks(key_name="JLOG",name="JLOG",title=u"JLOG",
    url="javascript:goJLOG()",target="_self",sequence=0,hint=u"JLOG")
headerlink.put()

這裡 url 中的 goJLOG() 函式是要在系統主版面 main.htm 中將工作日誌頁籤網頁載入 panel 的 center 區域, 所以必須到 template/main.htm 中新增此函式 :
   
    function gohome(){
      $(function(){
        var p=$("body").layout("panel","center");
        p.panel({href:"/hometabs"});
        });
      }
    function logout(){
      $(function(){
        $.messager.confirm("確認","確定要登出系統嗎?",function(btn){
          if (btn) {window.location.href="/logout";}
          });
        });
      }
    function goJLOG(){
      $(function(){
        var p=$("body").layout("panel","center");
        p.panel({href:"/jlogtabs"});
        });
      }

事實上就是將 gohome() 複製過來修改即可. 接下來就要去主控程式 main.py 中處理路徑與邏輯. 首先在最底下的 app 中新增上面新增的四個路徑 :

app = webapp2.WSGIApplication([
    ('/', MainHandler),
    ('/login', login),
    ('/check_member', check_member),
    ('/logout', logout),
    ('/hometabs', hometabs),
    ('/home', home),
    ('/change_theme', change_theme),
    ('/list_visitors', list_visitors),
    ('/get_visitors', get_visitors),
    ('/list_members', list_members),
    ('/get_members', get_members),
    ('/remove_visitors', remove_visitors),
    ('/add_member', add_member),
    ('/update_member', update_member),
    ('/remove_member', remove_member),
    ('/list_headerlinks', list_headerlinks),
    ('/get_headerlinks', get_headerlinks),
    ('/add_headerlink', add_headerlink),
    ('/update_headerlink', update_headerlink),
    ('/remove_headerlink', remove_headerlink),
    ('/list_navblocks', list_navblocks),
    ('/get_navblocks', get_navblocks),
    ('/add_navblock', add_navblock),
    ('/update_navblock', update_navblock),
    ('/remove_navblock', remove_navblock),
    ('/list_navlinks', list_navlinks),
    ('/get_navlinks', get_navlinks),
    ('/add_navlink', add_navlink),
    ('/update_navlink', update_navlink),
    ('/remove_navlink', remove_navlink),
    ('/settings', settings),
    ('/update_settings', update_settings),
    ('/member_settings', member_settings),
    ('/jlogtabs', jlogtabs),
    ('/jlog_home', jlog_home),
    ('/jlog_knowledge', jlog_knowledge),
    ('/jlog_admin', jlog_admin)
], debug=True, config=config)

然後建立四個類別來處理工作日誌的四個路徑, 首先是複製 hometabs 類別來改為 jlogtabs 類別,  然後複製 home 類別來改為 jlog_home, jlog_knowledge, 與 jlog_admin 類別, 分別渲染 hometabs.htm,  jlog_home.htm, jlog_knowledge.htm, 以及 jlog_admin.htm 這四個網頁模板 :

class jlogtabs(BaseHandler):
    def get(self):
        self.check_login()
        jlogtabs=m.Jlogtabs.all()
        jlogtabs.order("tab_order")  #sort by tab_order
        tabs=[]  #for storing tab objects
        is_admin=self.session.get('is_admin')  #True/False
        for t in jlogtabs:
            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/jlogtabs.htm"
        path=os.path.join(os.path.dirname(__file__), url)
        content=template.render(path,{"tabs":tabs})
        self.response.out.write(content)

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

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

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

這樣 main.py 也調整好了, 接下來就是到 /template 目錄下, 複製 hometabs.htm 為 jlogtabs.htm 修改如下 (只改 id 即可) :

<div id="jlogtabs" 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>

然後複製 home.htm 為 jlog_home.htm, jlog_knowledge.htm, 以及 jlog_admin.htm, 其內容暫時只是文字而已, 例如 jlog_admin.htm 內容為 :

<p>工作日誌管理</p>

OK, 這樣就完成新增應用服務作業了, 上傳雲端主機後按標頭連結 JLOG 即顯示工作日誌頁籤 :


按首頁又會載入 hometabs.htm, 如此即可在多個應用服務之間切換了. 每一個應用服務的入口主要是上面的標頭連結, 當然也可以在左方導覽區另行建立第二入口.

另外, 在新增 JLOG 時也發現標頭連結的排序程式不妥而加以調整, 就是將 main.py 中根目錄處理類別 MainHandler 中的 headerlinks 查詢物件從原先的逆向排序改為正向排序 :

        #create param: headerlinks
        headerlinks=m.Headerlinks.all()
        headerlinks.order("sequence")  #sort by sequence

然後把 model.py 中的 headerlink 實體排序如下, 即把系統的首頁與登入 sequence 分別改為 98 與 99, 應用服務則從 0 開始編號, 這樣在正向排序下系統標頭連結會排在最後面 :

headerlink=Headerlinks(key_name="JLOG",name="JLOG",title=u"JLOG",
    url="javascript:goJLOG()",target="_self",sequence=0,hint=u"JLOG")
headerlink.put()
headerlink=Headerlinks(key_name="home",name="home",title=u"首頁",
    url="javascript:gohome()",target="_self",sequence=98,hint=u"首頁")
headerlink.put()
headerlink=Headerlinks(key_name="logout",name="logout",title=u"登出",
    url="javascript:logout()",target="_self",sequence=99,hint=u"登出")
headerlink.put()

這樣這個小專案總算拚完了, 當然安全性上還有待改進 (例如密碼欄位的加密, 以及重設密碼的機制), 但至少可以正常運轉. 完整 zip 檔案可在下列連結下載 :

# 具有 JLOG 應用範例的 EasyuiCMS on GAE


2016年3月2日 星期三

EasyuiCMS on GAE 總整理

過去兩個月嘗試把一年前用 PHP 寫的 EasyuiCMS 移植到 GAE 上, 終於有了初步成果, 自從 2009 年底開始接觸 Google 的雲端主機服務 GAE 以來, 就有在 GAE 上寫一個簡單的架站系統的想法, 這樣我就可以很容易地在上面寫各種應用服務程式了. 這幾年大部分時間都花在 PHP 與 Java 的學習上, 對於 Python 一直沒有做系統化的學習, 在 GAE 上用 Python 也是見招拆招而已.

去年底休假時無意中看到以前寫的 GAE 程式, 就突然想動手試試看是否真能在 GAE 上寫個 CMS, 沒想到一寫就不能罷手, 至今整整兩個月總算有了小收穫, 把猜想化為可能, 今天我把這些結果重新整理, 刪除多餘的測試部分, 打包起來存檔, 以備後用.

目錄結構如下所示, /static/easyui 下放置 Easyui 函式庫, /static/images 下放置系統所需圖檔, 而 /templates 下則放置網頁模板 :


應用程式設置檔 app.yaml 內容如下 :

application: gaeweb1
version: 1
runtime: python27
api_version: 1
threadsafe: yes

handlers:
- url: /favicon\.ico
  static_files: favicon.ico
  upload: favicon\.ico
- url: /static
  static_dir: static
- url: .*
  script: main.app

libraries:
- name: webapp2
  version: "2.5.2"

這裡唯一需要修改的部分是第一個參數 application, 其中 gaeweb1 是我所申請的 GAE 應用程式帳號, 如果要把這 CMS 用在別的帳號, 只要改掉 application 之值即可.

其次是資料模型程式 model.py, 其內容如下 :

# -*- coding: utf-8 -*-
from google.appengine.ext import db

class Members(db.Model):
    account=db.StringProperty(required=True)
    password=db.StringProperty(required=True)
    name=db.StringProperty(required=False)
    theme=db.StringProperty(required=True,default="default")
    is_admin=db.BooleanProperty(required=True,default=False)
    email=db.EmailProperty(required=False)
    mobile=db.StringProperty(required=False)
    login_count=db.IntegerProperty(default=0)
    last_login_time=db.DateTimeProperty(auto_now_add=True)

class Visitors(db.Model):
    ip=db.StringProperty()
    visit_time=db.DateTimeProperty()
    user_agent=db.StringProperty()

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)

class Board(db.Model):
    poster=db.StringProperty()
    subject=db.StringProperty()
    content=db.TextProperty()
    post_time=db.DateTimeProperty()

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

class Hometabs(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()

class Headerlinks(db.Model):
    name=db.StringProperty()
    title=db.StringProperty()
    url=db.StringProperty()
    target=db.StringProperty()
    sequence=db.IntegerProperty()
    hint=db.StringProperty()

class Navblocks(db.Model):
    name=db.StringProperty()
    title=db.StringProperty()
    sequence=db.IntegerProperty()
    display=db.BooleanProperty()

class Navlinks(db.Model):
    name=db.StringProperty()
    title=db.StringProperty()
    url=db.StringProperty()
    target=db.StringProperty()
    sequence=db.IntegerProperty()
    block_name=db.StringProperty()
    hint=db.StringProperty()

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

member=Members(key_name="admin",account="admin",password="admin",name="admin",
    theme="default",is_admin=True,email="admin@foo.bar.com",mobile="0933")
member.put()
member=Members(key_name="guest",account="guest",password="guest",name="guest", theme="ui-cupertino",is_admin=False,email="guest@foo.bar.com",mobile="0932")
member.put()

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()

hometab=Hometabs(key_name="home",tab_name="home",tab_label=u"首頁",
    tab_link="/home",tab_order=0,tab_admin=False)
hometab.put()
hometab=Hometabs(key_name="member_settings",tab_name="member_settings", tab_label=u"使用者設定",tab_link="/member_settings",tab_order=1,tab_admin=False)
hometab.put()
hometab=Hometabs(key_name="list_visitors",tab_name="list_visitors",
    tab_label=u"訪客",tab_link="/list_visitors",tab_order=2,tab_admin=True)
hometab.put()
hometab=Hometabs(key_name="list_members",tab_name="list_members",
    tab_label=u"使用者",tab_link="/list_members",tab_order=3,tab_admin=True)
hometab.put()
hometab=Hometabs(key_name="list_headerlinks",tab_name="list_headerlinks",
    tab_label=u"標頭連結",tab_link="/list_headerlinks",tab_order=4,tab_admin=True)
hometab.put()
hometab=Hometabs(key_name="list_navblocks",tab_name="list_navblocks",
    tab_label=u"導覽區塊",tab_link="/list_navblocks",tab_order=5,tab_admin=True)
hometab.put()
hometab=Hometabs(key_name="list_navlinks",tab_name="list_navlinks",
    tab_label=u"導覽連結",tab_link="/list_navlinks",tab_order=6,tab_admin=True)
hometab.put()
hometab=Hometabs(key_name="settings",tab_name="settings",
    tab_label=u"系統設定",tab_link="/settings",tab_order=7,tab_admin=True)
hometab.put()

headerlink=Headerlinks(key_name="home",name="home",title=u"首頁",
    url="javascript:gohome()",target="_self",sequence=0,hint=u"首頁")
headerlink.put()
headerlink=Headerlinks(key_name="logout",name="logout",title=u"登出",
    url="javascript:logout()",target="_self",sequence=0,hint=u"登出")
headerlink.put()

navblock=Navblocks(key_name="main",name="main",title=u"主功能",
    sequence=0,display=True)
navblock.put()

navlink=Navlinks(key_name="home",name="home",title=u"首頁",
    url="javascript:gohome()",target="_self",sequence=0,block_name="main",
    hint=u"首頁")
navlink.put()
navlink=Navlinks(key_name="logout",name="logout",title=u"登出",
    url="javascript:logout()",target="_self",sequence=0,block_name="main",
    hint=u"登出")
navlink.put()

這裡我給 Members 添加了 login_count 與 last_login_time 這兩個欄位來記錄使用者登入次數與最近一次登入時間, 在 main.py 中處理登入時會累計登入次數並更新登入時間. 其所渲染的 list_members.htm 也配合修改如下 :

      $('#members').datagrid({
        columns:[[
          {field:'account',title:'帳號',sortable:true},
          {field:'password',title:'密碼',sortable:true},
          {field:'theme',title:'主題布景',sortable:true},
          {field:'is_admin',title:'管理員',sortable:true},
          {field:'email',title:'Email',sortable:true},
          {field:'mobile',title:'行動電話',sortable:true},
          {field:'login_count',title:'登入次數',sortable:true},
          {field:'last_login_time',title:'最近登入',sortable:true}
          ]],
      url:"/get_members",
      method:"post",
      singleSelect:true,
      fitColumns:true,
      rownumbers:true,
      pagination:true,
      pageSize:10
      });


另外也拿掉以前測試 Visitors 時為了全文搜尋而加的 ip_list 與 user_agent_list 欄位, 僅做 "search_what%" 半模糊比對 (即僅開頭符合), 而且搜尋框也改用 textbox 內含 icon 方式. 其次是將原來的 Systabs 改為 Hometabs, 因為既然回首頁按鈕為呼叫 gohome(), 而且首頁就在此頁籤架構中, 稱為 Hometabs 較名符其實. 當然主程式 main.py 也要配合修改.

在 main.py 中我做了蠻大修訂, 首先為了安全性考量, 我在 BaseHandler 類別中增加一個 check_login() 的函式讓各路徑處理類別在一開始時呼叫, 以檢驗是否已建立連線 Session 物件, 沒有的話一律重導向至登入頁面, 避免使用者直接存取各 url 路徑. 當然, 各路徑處理類別除了 login 外都改為繼承 BaseHandler 而非 webapp2.RequestHandler, 因為要用到 Session 機制之故 (其實 BaseHandler 是 RequestHandler 的子類別) :

# -*- coding: utf-8 -*-
import os
import json
import datetime
import webapp2
import model as m
from datetime import timedelta
from webapp2_extras import sessions
from google.appengine.ext import db
from google.appengine.ext.webapp import template

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
    def check_login(self):
        account=self.session.get('account')
        if account is None:
            self.redirect("/login")

登入登出的路徑處理類別如下 :

class login(webapp2.RequestHandler):
    def get(self):
        s=m.Settings.get_by_key_name("settings")
        info={}
        if s is None:
            info["site_title"]=""
            info["site_theme"]="ui-cupertino"
        else:
            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)

class check_member(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)
        mb=query.get()
        if mb is None: #user not existed
            content='{"result":"failure","reason":"帳號或密碼錯誤"}'
        else: #user existed
            #check site_state (on/off)
            s=m.Settings.get_by_key_name("settings")
            site_state=s.site_state
            #go main page if system is on or user is admin
            if site_state=="on" or mb.is_admin:
                #save session
                self.session['account']=mb.account     
                self.session['theme']=mb.theme         
                self.session['is_admin']=mb.is_admin
                #update last_login_time & login_count of the user
                mb.last_login_time=datetime.datetime.now() + timedelta(hours=+8)
                mb.login_count=mb.login_count + 1
                mb.put()
                content='{"result":"success"}'
            else: #system is on maintanance (off) and not admin
                content='{"result":"failure","reason":"系統維護中, 請稍候."}'
        self.response.out.write(content)

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

在 login 類別中會將 site_theme 傳入 login.htm 中, 用來更改此網頁的主題布景, 這裡我改用字串處理方式來製作 css 檔的路徑, 這樣不管 easyui 是使用 CDN 與自行供檔都會產生所需之路徑. 注意, 在 check_member 類別中, 若登入成功, 會將 login_count 欄位增量 1, 且更新 last_login_time 欄位. 而當 sys_settings 資料表中的 site_state 被設為 off, 且使用者不是管理員時, 系統不會讓使用者登入, 登入檢查會失敗, 並回應系統維護中訊息.



另外我把原先的 login_check 類別改名為 check_member, 因為那很容易跟 BaseHandler 類別裡的 check_login() 函式搞混. 當然, login.htm 中登入網頁的路徑也要同步修改 :

      //將主題布景改為系統設定
      var href=$("#theme").attr("href");
      var css="themes/{{info.site_theme}}/easyui.css"
      href=href.substr(0,href.indexOf("themes")) + css;
      $("#theme").attr("href", href);
      $("#login-form").form({ //設定表單
        url:"/check_member",
        success:function(data){
          var data=eval('(' + data + ')');  //將 JSON 轉成物件
          if (data.result=="success") {window.location.href='/';}
          else {$("#msg").text(data.reason);}
          }
        });

下面是更改個人主題布景的路徑處理類別 :

class change_theme(BaseHandler):
    def get(self):
        self.check_login()
        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

首頁頁籤架構處理 :

class hometabs(BaseHandler):
    def get(self):
        self.check_login()
        hometabs=m.Hometabs.all()
        hometabs.order("tab_order")  #sort by tab_order
        tabs=[]  #for storing tab objects
        is_admin=self.session.get('is_admin')  #True/False
        for t in hometabs:
            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/hometabs.htm"
        path=os.path.join(os.path.dirname(__file__), url)
        content=template.render(path,{"tabs":tabs})
        self.response.out.write(content)

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

這個首頁 home.htm 也是維持僅顯示 "Welcome" 字樣, 寫應用程式時再視需要修改即可 (例如顯示應用服務的 Dashboard 畫面或服務摘要表等).

訪客紀錄器類別如下 :

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

class get_visitors(BaseHandler):
    def post(self):
        self.check_login()
        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):
            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))

class remove_visitors(BaseHandler):
    def post(self):
        self.check_login()
        visitors=m.Visitors.all()
        for v in visitors:
            db.delete(v)
        content='{"status":"success"}'          
        self.response.out.write(content)

因為在 model.py 中我已經拿掉 ip_list 與 user_agent_list 欄位, 因此這裡的資料實體搜尋也去掉對此兩欄位的全文搜尋, 改為 "search_what%" 的前面符合搜尋. 而它所渲染的 list_visitors.htm 網頁, 其中下拉式選單的選項值也要修改, 將 ip_list 與 user_agent_list 改為 ip 與 user_agent 如下 :

{% 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_visitors" 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>
      <option value="user_agent">瀏覽器</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 位址',width:150,sortable:true},
          {field:'visit_time',title:'到訪時間',width:150,sortable:true},
          {field:'user_agent',title:'瀏覽器',sortable:true}
          ]],
        singleSelect:true,
        fitColumns:true,
        collapsible:true,
        rownumbers:true,
        pagination:true,
        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",{search_field:""});
        });
      $("#remove_visitors").bind("click",function(){
        $.messager.confirm("確認","確定要刪除全部訪客紀錄嗎?",function(btn){
          if (btn){
            var params={};
            var callback=function(data){
              if (data.status==="success"){$("#visitors").datagrid("reload");}
              else {$.messager.alert("訊息","刪除訪客紀錄失敗!","error");}
              }
            $.post("/remove_visitors",params,callback,"json");
            }
          });
        });
      });
  </script>
{% endblock%}


此處在搜尋框 search_what 中添加 iconCls:"icon-search" 就會在文字框內出現一個放大鏡搜尋圖樣按鈕. 當按右上角的 reload 按鈕時, 會觸發 datagrid 重新載入, 但是若之前有搜尋動作, 那麼重載時仍會傳送上次的 search_field 參數, 導致不是載入全部資料, 而是與上次搜尋相同的結果, 與預期動作不符. 解決之道是傳入一個空字串的 search_field 參數即可.

其次是我把 remove_visitor 類別改為 remove_visitors, 主要考量是 GAE 不像 MySQL 那樣可用一個自動增量主鍵 id 簡單鎖定要刪除的那筆紀錄, 雖然可用 key_name 來做, 但每一筆訪客紀錄都要設 key_name 也很麻煩, 只能使用日期時間, 但麻煩的是資料庫裡儲存的是精確的日期時間類型, 但網頁顯示的只到秒, 導致從 datagrid 點選一筆要刪除時根本無法真正將資料實體刪除, 因為在搜尋時找不到那筆實體. 當然可以乾脆將 visit_time 改為 StringProperty 類型來解決, 但是想到訪客紀錄有需要一筆一筆殺嗎? 它只是用來看看有哪些人來瀏覽而已, 看完就清空即可, 所以這裡 remove_visitors 類別是刪除全部訪客紀錄實體.

下面是使用者管理相關路徑處理類別, 與上一篇文章內容差不多, 只是改為繼承 BaseHandler 與加入 check_login() :

class list_members(BaseHandler):
    def get(self):
        self.check_login()
        #query Themes from datastore
        themes=m.Themes.all()
        info={}
        theme_list=[]
        for t in themes:
            theme_list.append(t.theme)
        info["themes"]=theme_list
        url="templates/list_members.htm"
        path=os.path.join(os.path.dirname(__file__), url)
        content=template.render(path,{'info':info})
        self.response.out.write(content)

class get_members(BaseHandler):
    def post(self):
        self.check_login()
        page=self.request.get("page")
        rows=self.request.get("rows")
        sort=self.request.get("sort")
        order=self.request.get("order")
        if len(page):
            page=int(page)
        else:
            page=1
        if len(rows):
            rows=int(rows)
        else:
            rows=10
        if not len(sort):
            sort="account"
        if not len(order):
            order="asc"
        query=m.Members.gql("ORDER BY %s %s" % (sort, order))
        count=query.count()
        mbs=query.fetch(rows, (page-1)*rows)
        rows=[]  #for storing objects
        for mb in mbs:
            member={"account":mb.account,
                    "password":mb.password,
                    "theme":mb.theme,
                    "is_admin":mb.is_admin,
                    "email":mb.email,
                    "mobile":mb.mobile}
            rows.append(member)
        obj={"total":count,"rows":rows}  #Easyui datagrid json format
        self.response.headers["Content-Type"]="application/json"
        self.response.out.write(json.dumps(obj))

class add_member(BaseHandler):
    def post(self):
        self.check_login()
        account=self.request.get("account")
        password=self.request.get("password")
        theme=self.request.get("theme")
        is_admin=self.request.get("is_admin")
        email=self.request.get("email", default_value="foo@bar.com")
        mobile=self.request.get("mobile", default_value="0933123456")
        #trans string to boolean
        if is_admin=="True":
            is_admin=True
        else:
            is_admin=False
        #check entity if exist
        mb=m.Members.get_by_key_name(account)
        if mb: #already exist
            result='{"status":"failure","reason":"帳號已存在!"}'
        else:  #new member
            member=m.Members(key_name=account,
                account=account,
                password=password,
                theme=theme,
                is_admin=is_admin,
                email=email,
                mobile=mobile
                )
            member.put()
            result='{"status":"success"}'      
        self.response.out.write(result)

class update_member(BaseHandler):
    def post(self):
        self.check_login()
        account=self.request.get("account")
        password=self.request.get("password")
        theme=self.request.get("theme")
        is_admin=self.request.get("is_admin")
        email=self.request.get("email")
        mobile=self.request.get("mobile", default_value="")
        #trans string to boolean
        if is_admin=="True":
            is_admin=True
        else:
            is_admin=False
        #get entity from store
        mb=m.Members.get_by_key_name(account)
        if mb: #entity exist
            mb.account=account
            mb.password=password
            mb.theme=theme
            mb.is_admin=is_admin
            mb.email=email
            mb.mobile=mobile
            mb.put()
            result='{"status":"success"}'
        else:  #member not existed
            result='{"status":"failure","reason":"使用者不存在!"}'      
        self.response.out.write(result)

class remove_member(BaseHandler):
    def post(self):
        self.check_login()
        account=self.request.get("account")
        #get entity from store
        mb=m.Members.get_by_key_name(account)
        if mb: #entity exist
            db.delete(mb)          
            result='{"status":"success"}'
        else:  #member not existed
            result='{"status":"failure","reason":"使用者不存在!"}'      
        self.response.out.write(result)

下面是標頭超連結管理相關之路徑處理類別 :

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

class get_headerlinks(BaseHandler):
    def post(self):
        self.check_login()
        page=self.request.get("page")
        rows=self.request.get("rows")
        sort=self.request.get("sort")
        order=self.request.get("order")
        if len(page):
            page=int(page)
        else:
            page=1
        if len(rows):
            rows=int(rows)
        else:
            rows=10
        if not len(sort):
            sort="sequence"
        if not len(order):
            order="asc"
        query=m.Headerlinks.gql("ORDER BY %s %s" % (sort, order))
        count=query.count()
        links=query.fetch(rows, (page-1)*rows)
        rows=[]  #for storing objects
        for h in links:
            link={"name":h.name,
                  "title":h.title,
                  "url":h.url,
                  "target":h.target,
                  "sequence":h.sequence,
                  "hint":h.hint}
            rows.append(link)
        obj={"total":count,"rows":rows}  #Easyui datagrid json format
        self.response.headers["Content-Type"]="application/json"
        self.response.out.write(json.dumps(obj))

class add_headerlink(BaseHandler):
    def post(self):
        self.check_login()
        name=self.request.get("name")
        #check entity if exist
        link=m.Headerlinks.get_by_key_name(name)
        if link: #already exist
            result='{"status":"failure","reason":"連結名稱已存在!"}'
        else:  #new link
            headerlink=m.Headerlinks(key_name=name,name=name,
                title=self.request.get("title"),
                url=self.request.get("url"),
                target=self.request.get("target"),
                sequence=int(self.request.get("sequence")),
                hint=self.request.get("hint")
                )
            headerlink.put()
            result='{"status":"success"}'      
        self.response.out.write(result)

class update_headerlink(BaseHandler):
    def post(self):
        self.check_login()
        name=self.request.get("name")
        #get entity from store
        link=m.Headerlinks.get_by_key_name(name)
        if link: #entity exist
            link.title=self.request.get("title")
            link.url=self.request.get("url")
            link.target=self.request.get("target")
            link.sequence=int(self.request.get("sequence"))
            link.hint=self.request.get("hint")
            link.put()
            result='{"status":"success"}'
        else:  #link not existed
            result='{"status":"failure","reason":"連結不存在!"}'      
        self.response.out.write(result)

class remove_headerlink(BaseHandler):
    def post(self):
        self.check_login()
        name=self.request.get("name")
        #get entity from store
        link=m.Headerlinks.get_by_key_name(name)
        if link: #entity exist
            db.delete(link)          
            result='{"status":"success"}'
        else:  #link not existed
            result='{"status":"failure","reason":"連結不存在!"}'      
        self.response.out.write(result)

下列是導覽區塊管理相關之路徑處理類別 :

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

class get_navblocks(BaseHandler):
    def post(self):
        self.check_login()
        page=self.request.get("page")
        rows=self.request.get("rows")
        sort=self.request.get("sort")
        order=self.request.get("order")
        if len(page):
            page=int(page)
        else:
            page=1
        if len(rows):
            rows=int(rows)
        else:
            rows=10
        if not len(sort):
            sort="sequence"
        if not len(order):
            order="asc"
        query=m.Navblocks.gql("ORDER BY %s %s" % (sort, order))
        count=query.count()
        blocks=query.fetch(rows, (page-1)*rows)
        rows=[]  #for storing objects
        for b in blocks:
            block={"name":b.name,
                   "title":b.title,
                   "sequence":b.sequence,
                   "display":b.display}
            rows.append(block)
        obj={"total":count,"rows":rows}  #Easyui datagrid json format
        self.response.headers["Content-Type"]="application/json"
        self.response.out.write(json.dumps(obj))

class add_navblock(BaseHandler):
    def post(self):
        self.check_login()
        name=self.request.get("name")
        display=self.request.get("display")
        if display=="True":
            display=True
        else:
            display=False
        #check entity if exist
        block=m.Navblocks.get_by_key_name(name)
        if block: #already exist
            result='{"status":"failure","reason":"導覽區塊已存在!"}'
        else:  #new block
            navblock=m.Navblocks(key_name=name,name=name,
                title=self.request.get("title"),
                sequence=int(self.request.get("sequence")),
                display=display
                )
            navblock.put()
            result='{"status":"success"}'      
        self.response.out.write(result)

class update_navblock(BaseHandler):
    def post(self):
        self.check_login()
        name=self.request.get("name")
        display=self.request.get("display")
        if display=="True":
            display=True
        else:
            display=False
        #get entity from store
        block=m.Navblocks.get_by_key_name(name)
        if block: #entity exist
            block.title=self.request.get("title")
            block.sequence=int(self.request.get("sequence"))
            block.display=display
            block.put()
            result='{"status":"success"}'
        else:  #block not existed
            result='{"status":"failure","reason":"導覽區塊不存在!"}'      
        self.response.out.write(result)

class remove_navblock(BaseHandler):
    def post(self):
        self.check_login()
        name=self.request.get("name")
        #get entity from store
        block=m.Navblocks.get_by_key_name(name)
        if block: #entity exist
            db.delete(block)
            #delete navlinks belong to this navblock
            query=m.Navlinks.all()
            links=query.filter("block_name",name)
            for link in links:
                db.delete(link)
            result='{"status":"success"}'
        else:  #block not existed
            result='{"status":"failure","reason":"導覽區塊不存在!"}'      
        self.response.out.write(result)

下面是導覽列超連結管理相關之路徑處理類別 :

class list_navlinks(BaseHandler):
    def get(self):
        self.check_login()
        blocks=m.Navblocks.all()
        info=[]
        for b in blocks:
            block={}
            block["block_name"]=b.name
            block["block_title"]=b.title
            #block["block_title"]=b.title + " (" + b.name + ")"
            info.append(block)
        url="templates/list_navlinks.htm"
        path=os.path.join(os.path.dirname(__file__), url)
        content=template.render(path,{"info":info})
        self.response.out.write(content)

class get_navlinks(BaseHandler):
    def post(self):
        self.check_login()
        page=self.request.get("page")
        rows=self.request.get("rows")
        sort=self.request.get("sort")
        order=self.request.get("order")
        if len(page):
            page=int(page)
        else:
            page=1
        if len(rows):
            rows=int(rows)
        else:
            rows=10
        if not len(sort):
            sort="sequence"
        if not len(order):
            order="asc"
        query=m.Navlinks.gql("ORDER BY %s %s" % (sort, order))
        count=query.count()
        links=query.fetch(rows, (page-1)*rows)
        rows=[]  #for storing objects
        for h in links:
            link={"name":h.name,
                  "title":h.title,
                  "url":h.url,
                  "target":h.target,
                  "sequence":h.sequence,
                  "block_name":h.block_name,
                  "hint":h.hint}
            rows.append(link)
        obj={"total":count,"rows":rows}  #Easyui datagrid json format
        self.response.headers["Content-Type"]="application/json"
        self.response.out.write(json.dumps(obj))

class add_navlink(BaseHandler):
    def post(self):
        self.check_login()
        name=self.request.get("name")
        #check entity if exist
        link=m.Navlinks.get_by_key_name(name)
        if link: #already exist
            result='{"status":"failure","reason":"連結名稱已存在!"}'
        else:  #new link
            navlink=m.Navlinks(key_name=name,name=name,
                title=self.request.get("title"),
                url=self.request.get("url"),
                target=self.request.get("target"),
                sequence=int(self.request.get("sequence")),
                block_name=self.request.get("block_name"),
                hint=self.request.get("hint")
                )
            navlink.put()
            result='{"status":"success"}'      
        self.response.out.write(result)

class update_navlink(BaseHandler):
    def post(self):
        self.check_login()
        name=self.request.get("name")
        #get entity from store
        link=m.Navlinks.get_by_key_name(name)
        if link: #entity exist
            link.title=self.request.get("title")
            link.url=self.request.get("url")
            link.target=self.request.get("target")
            link.sequence=int(self.request.get("sequence"))
            link.block_name=self.request.get("block_name")
            link.hint=self.request.get("hint")
            link.put()
            result='{"status":"success"}'
        else:  #link not existed
            result='{"status":"failure","reason":"連結不存在!"}'      
        self.response.out.write(result)

class remove_navlink(BaseHandler):
    def post(self):
        self.check_login()
        name=self.request.get("name")
        #get entity from store
        link=m.Navlinks.get_by_key_name(name)
        if link: #entity exist
            db.delete(link)          
            result='{"status":"success"}'
        else:  #link not existed
            result='{"status":"failure","reason":"連結不存在!"}'      
        self.response.out.write(result)

下面是系統設定管理相關之路徑處理類別 :

class settings(BaseHandler):
    def get(self):
        self.check_login()
        info={}  #for storing parameters
        #query settings from datastore
        settings=m.Settings.get_by_key_name("settings")
        if settings: #entity exist
            info["site_title"]=settings.site_title
            info["site_theme"]=settings.site_theme
            info["site_state"]=settings.site_state
        else:
            info["site_title"]=""
            info["site_theme"]="default"
            info["site_state"]="off"
        #query Themes from datastore
        themes=m.Themes.all()
        theme_list=[]
        for t in themes:
            theme_list.append(t.theme)
        info["themes"]=theme_list
        url="templates/settings.htm"
        path=os.path.join(os.path.dirname(__file__), url)
        content=template.render(path,{'info':info})
        self.response.out.write(content)

class update_settings(BaseHandler):
    def post(self):
        self.check_login()
        #get entity from store
        settings=m.Settings.get_by_key_name("settings")
        if settings: #entity exist
            settings.site_title=self.request.get("site_title")
            settings.site_theme=self.request.get("site_theme")
            settings.site_state=self.request.get("site_state")
            settings.put()
            result='{"status":"success"}'
        else:  #entity not existed
            result='{"status":"failure"}'      
        self.response.out.write(result)

最後是根目錄處理類別與路徑處理指派 :

class MainHandler(BaseHandler):
    def get(self):
        #save visitor info first
        ip=self.request.remote_addr
        user_agent=self.request.headers.get("User-Agent")
        # or user_agent=os.environ.get("HTTP_USER_AGENT")
        visitor=m.Visitors()
        visitor.ip=ip
        #adapt UTC to Taipei Time Zone
        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
        self.check_login()
        #pass: already login, render main.htm
        info={}  #for storing parameters
        #create param: account, site_title, theme
        account=self.session.get('account')
        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
        #create param: greeting
        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()]
        #create param: themes
        theme_list=[]
        themes=m.Themes.all()
        for t in themes:
            theme_list.append(t.theme)
        info["themes"]=theme_list
        #create param: headerlinks
        headerlinks=m.Headerlinks.all()
        headerlinks.order("-sequence")  #sort by sequence (reverse)
        link_list=[]  #for storing headerlinks objects
        for h in headerlinks:
            link={}
            link["title"]=h.title
            link["url"]=h.url
            link["target"]=h.target
            link["sequence"]=h.sequence
            link["hint"]=h.hint
            link_list.append(link)
        info["headerlinks"]=link_list
        #create param: navblocks & navlinks
        navblocks=m.Navblocks.all()
        navblocks.filter("display =",True)
        navblocks.order("sequence")  #sort by sequence          
        navblock_list=[]  #for storing navblocks objects
        for nb in navblocks:
            navblock={}
            navblock["title"]=nb.title  #store block title
            #query nvavlinks belongs to this block
            query=m.Navlinks.all()
            navlinks=query.filter("block_name =",nb.name)
            navlinks.order("sequence")
            navlink_list=[]  #for storing navblinks objects
            for nl in navlinks:
                navlink={}
                navlink["title"]=nl.title
                navlink["url"]=nl.url
                navlink["target"]=nl.target
                navlink["hint"]=nl.hint
                navlink_list.append(navlink) #store this link
            navblock["navlinks"]=navlink_list #store block links
            navblock_list.append(navblock)  #store this block
        info["navblocks"]=navblock_list
        url="templates/main.htm"
        path=os.path.join(os.path.dirname(__file__), url)
        content=template.render(path,{'info':info})
        self.response.out.write(content)

config={}
config['webapp2_extras.sessions']={'secret_key':'my-super-secret-key'}
app = webapp2.WSGIApplication([
    ('/', MainHandler),
    ('/login', login),
    ('/check_member', check_member),
    ('/logout', logout),
    ('/hometabs', hometabs),
    ('/home', home),
    ('/change_theme', change_theme),
    ('/list_visitors', list_visitors),
    ('/get_visitors', get_visitors),
    ('/list_members', list_members),
    ('/get_members', get_members),
    ('/remove_visitors', remove_visitors),
    ('/add_member', add_member),
    ('/update_member', update_member),
    ('/remove_member', remove_member),
    ('/list_headerlinks', list_headerlinks),
    ('/get_headerlinks', get_headerlinks),
    ('/add_headerlink', add_headerlink),
    ('/update_headerlink', update_headerlink),
    ('/remove_headerlink', remove_headerlink),
    ('/list_navblocks', list_navblocks),
    ('/get_navblocks', get_navblocks),
    ('/add_navblock', add_navblock),
    ('/update_navblock', update_navblock),
    ('/remove_navblock', remove_navblock),
    ('/list_navlinks', list_navlinks),
    ('/get_navlinks', get_navlinks),
    ('/add_navlink', add_navlink),
    ('/update_navlink', update_navlink),
    ('/remove_navlink', remove_navlink),
    ('/settings', settings),
    ('/update_settings', update_settings)
], debug=True, config=config)

這裡藍色就是有修改的部分, 在 MainHandler 部分因為已利用 check_login() 檢查連線狀態, 因此通過後就直接渲染 main.htm 網頁了.

好了, 以上就是這次為期兩個月玩 GAE 的成果紀錄, 不記下來保證一個月後都忘光光. 檔案全部壓縮封存候用 (下載原始碼) (備用下載點). GAE 要暫時 hold 一下, 因為我要開始忙工作日誌了, 希望年底前可以交差.

2016-03-04 補充 :

今天重新審視了一遍程式碼, 又發現了一些 bug 與須改進之處 :

1. 對話框增加取消鈕 :

新增資料對話框增加了 "取消" 按鈕, 按下時會將對話框關閉, 例如新增使用者對話框取消動作處理程式為 :

    $("#cancel_member").bind("click",function(){
      $("#member_dialog").dialog("close");
      });


2. Members 資料表新增 name 欄位, list_members 增加搜尋框.

3. 新增 "使用者設定" 頁籤, 讓使用者可自行改密碼, Email 等.


4. 導覽區塊, 導覽連結, 標頭連結等列表都改為分頁方式顯示.

5. 有搜尋的 Datagrid 重新載入鈕 (reload) 傳送參數 search_field="" :

避免上次搜尋的 search_field 值在 reload 時持續傳出值, 使得無法載入全部資料. 這樣就界定了 datagrid 列表右上角之 reload 為 "全部載入", 而下方分頁工具列上的 reload 為 "現況載入" (之前有搜尋的話會持續).

另外, 我測試了 CDN 供給 Easyui 函式庫 (刪除 jqueryeasyui.htm 這個檔案, 然後將 jqueryeasyui_cdn.htm 複製為 jqueryeasyui.htm), 發現目前已可使用了 (前陣子的 bug 改好了?), 所以可以把 static 目錄下的 easyui 資料夾刪除了, 這樣壓縮後的 zip 只有 33K 而已, 如果自備 Easyui 函式庫的話, 壓縮後就要 814K, 還真是大.

# 更新版下載