# 氣候變遷競賽 網頁後端Django架構技術文件 ### 以Django做為架構架設網頁後端, 做出符合需求的網頁介面 Django是使用Python語言的網頁架構,採用MTV pattern,可免費快速開發、建立個別app分工、支援SQL資料庫,適合專題快速且可變的網站架設。 MTV pattern (Models-Template-View): Models : 定義並紀錄資料種類、儲存進SQL資料庫 Template : 簡易HTML模板,這個部分由前端專責負責 View : 資料流的控制與使用者request、response的處理 View中加入使用者登錄與驗證、偽裝使用者攻擊防患、csrf(跨站請求偽造)攻擊防患、使用者id辨識、資料分級管理等等安全性的管理,並且讓使用者只能看到自己擁有的專案。 在整個空污防護網的架構下, 後端負責接收處理完的資料並存至SQL資料庫中, 在不同使用者 (可以是用戶或是專案開發人員) 開啟網路瀏覽器至前端進行request時, 依照不同的用戶權限給予不同的資料, return給前端進行渲染 在未來可能出現多個專案與多個使用者的情況, 所以後端基本上需要配置資料的儲存與提取, 並管理不同使用者的權限, 例如讓用戶收到精簡的解析過的aerobox資料讓人一目了然, 而讓專案人員除了以上資訊還可以收到儀器狀態與使用的情況 詳細Django開發文件及詳細功能請參考以下: ## storyweb架構如下: > [黑色字為資料夾,藍色字為py檔案] 1. storyweb(含**urls.py**, **setting.py**, **init.py**) 2. **manage.py**(主要控制的py檔案) 3. dataAPI(資料控管與輸出 app) 4. bot(telegram bot app) 5. db.sqlite3(SQL資料庫) 我們待會的結構討論主要分成: 1. storyweb結構討論 2. dataAPI app結構討論 3. bot app結構討論 ## 1.storyweb結構討論 ### 1.1 storyweb中**urls.py**: ```python from django.conf.urls import include, url from django.urls import path from django.contrib import admin from dataAPI.views import index, post_detail, dashboard ``` 引用**admin**管理後台, **url**功能以及**dataAPI.views**中的數個方法 ```python urlpatterns = [ path('admin/', admin.site.urls), path('', index), path('projects/get/<last>/<bd>/<bt>/<ed>/<et>',post_detail),#, name='post_detail'), path('dashboard/<pk>/<last>/<bd>/<bt>/<ed>/<et>',dashboard),#,name='dashboard'), path('telegram_bot/',include('bot.urls','bot')) ] ``` 建立5個urls: * "/admin/" : 對應內建後臺管理方法 * "/" : 對應**index**方法,為登錄頁面 * "/projects/get/<last>/<bd>/<bt>/<ed>/<et>" : 對應**post_detail**方法,用以確認使用者身分與其他資料 * "/dashboard/<pk>/<last>/<bd>/<bt>/<ed>/<et>" : 對應**dashboard**方法,呈現json資料予前端 * "/telegram_bot/..." : 後方仍有urls,交給bot.urls接續 ### 1.2 storyweb中**setting.py**重要的部分: ```python # 允許的host(填*則表示皆可) ALLOWED_HOSTS = ['*'] # 有用到的app(新增app1,aerobox_api) INSTALLED_APPS = ( 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', 'dataAPI', 'bot', ) # 中繼模組 MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', ] # 哪個檔案控制urls ROOT_URLCONF = 'storyweb.urls' # 資料庫種類(SQL) DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), } } ``` ## 2. dataAPI app結構討論 ### 2.1 dataAPI的models.py ```python from django.db import models from django.contrib.auth.models import User import hashlib import os ``` 從Django內建程式庫引入**models**以及**User**功能 引入**hashlib**, **os** 用以檢測"personal_key",確認登入者為正常使用者而非惡意資安攻擊 ```python def create_key(): return hashlib.md5(os.urandom(32)).hexdigest() ``` create_key方法,每次登入都會產生隨機的32位hash以md5方式隨機加密使用者每次登入的id,以防冒名使用者的資安漏洞 ```python class AeroboxData(models.Model): pm = models.FloatField(blank=True) temp = models.FloatField(blank=True) rh = models.IntegerField(blank=True) co2 = models.IntegerField(blank=True) lon = models.FloatField(blank=True) lat = models.FloatField(blank=True) time = models.DateTimeField(auto_now_add=True) ``` **AeroboxData**類別, 代表一台Aerobox擁有的資料,具有屬性: * pm (FloatField) * temp (FloatField) * rh (IntegerField) * co2 (IntegerField) * lon (FloatField) * lat (FloatField) * time (DateTimeField) ```python class Aerobox(models.Model): aerobox_id = models.CharField(max_length=100,default=1) sitename = models.CharField(max_length=100,default=1) aeroboxdata = models.ManyToManyField(AeroboxData) ``` **Aerobox**類別, 代表一台Aerobox, 具有屬性: * aerobox_id (CharField) * sitename (CharField) * aeroboxdata (以ManyToManyField的方式連結到**AeroboxData**類別) ```python class ProjectData(models.Model): pj_name = models.CharField(max_length=100,default=1) start_time = models.DateTimeField(auto_now_add=True) end_time = models.DateTimeField(auto_now=True) aerobox_data = models.ManyToManyField(AeroboxData) ``` **ProjectData**類別, 代表一個project(一個project可能有多台aerobox), 具有屬性: * pj_name (CharField) * start_time (DateTimeField) * end_time (DateTimeField) * aerobox (以ManyToManyField的方式連結到**Aerobox**類別) ```python class UserExtension(models.Model): user = models.ForeignKey(User,on_delete=models.CASCADE)##ForeignKey==onetoone u_name = models.CharField(max_length=100,default=1) personal_key = models.CharField(max_length=33,blank=True,default=create_key,unique=True) projectdata = models.ManyToManyField(ProjectData) ``` **UserExtension**類別, 代表一個使用者,具有屬性: * user (ForeignKey)一對一關係,連接到Django內建使用者方法 (更改on_delete方法為models.CASCADE以確保User刪除時UserExtension會一併消失) * u_name (CharField) * personal_key (CharField)(登入者將獲得一串加密的personal_key,用以識別此登錄者) * projectdata (以ManyToManyField的方式連結到**ProjectData**類別) ----------------------------------------------------------- 綜上所述的資料層級: 最低層 AeroboxData => Aerobox => ProjectData => UserExtension 最高層 因此我們可以整理資料組織如下: >UserExtension >>user >>u_name >>personal_key >>ProjectData >>>pj_name >>>start_time >>>end_time >>>Aerobox >>>>aerobox_id >>>>sitename >>>>AeroboxData >>>>>pm >>>>>temp >>>>>rh >>>>>co2 >>>>>lon >>>>>lat >>>>>time 其中**UserExtension**<->**ProjectData**的關係以及**ProjectData**<->**Aerobox**的關係均為**ManyToManyField** ### 2.2 dataAPI的views.py ```python from django.shortcuts import render, redirect from .models import UserExtension from django.contrib import auth from django.contrib.auth import authenticate from django.contrib.auth.models import User from django.contrib.auth.decorators import login_required from django.http import HttpResponse, HttpResponseRedirect from django.http import JsonResponse from django.views.decorators.csrf import csrf_exempt from datetime import datetime import pytz import os import hashlib ``` * 從django內建的shortcuts中引用**render**, **redirect**用以傳送資料或重新導向 * 從aerobox_api的models中引用**ProjectData**資料 * 從models中引用**UserExtension**資料 * 從django內建的contrib中引用**auth, authenticate, User, login_required**等登錄功能程式集 * 從django內建的http中引用**HttpResponse, HttpResponseRedirect, JsonResponse**用以return Http或是Json形式的資料給前端或是重新將使用者的urls進行導向 * 從django內建的views引用**csrf_exempt**以防止csrf網路攻擊 * 引入datetime計時使用 * 引入**hashlib, os** 檢測personal_key(使用者登入id) ```python def index(request): print(request.POST) #print(request.GET.get('a')) if request.method=="POST": print("index login success!! ") username=request.POST.get('username') password=request.POST.get('password') user=auth.authenticate(username=username,password=password) if user and user.is_active: auth.login(request, user) user_e, created = UserExtension.objects.get_or_create(user=user) user_e.personal_key = hashlib.md5(os.urandom(32)).hexdigest() user_e.save(update_fields=['personal_key']) #change p_k only return HttpResponseRedirect('/projects/get/'+request.POST.get('last')+'/'\ +request.POST.get('bd')+'/'+request.POST.get('bt')+'/'+request.POST.get('ed')+'/'+request.POST.get('et')) else: return HttpResponse("index login did't success!!") return render(request, 'storyboard.html') ``` 定義index方法: 1.若request的method是POST(即輸入帳號密碼及其他資料按登入後),則伺服器端印出"index login success!!",並且將request.POST中的username以及password記錄下來(即登錄者資料) 再用username以及password進行查找符合的使用者,結果傳給user變數 若user為真(此使用者帳號存在)而且並未被凍結則登入 接著以user查找資料庫UserExtension中是否有物件的user屬性=這裡的user 若有=>就傳給user_e 若沒有=>就創建一個UserExtension資料給user_e 接著將user_e的personal_key屬性以hashlib的方式賦值並儲存 最後重新導向"/monitor" 而若user為假(此使用者帳號不存在)或是被凍結則秀出"index login did't success!!!!" 2.若request的方法為"GET"時(即打開login頁面時)給使用者一個簡單的login.html頁面(位於dataAPI裡的template)登錄帳號密碼即可 >此為登入介面 --- ```python @login_required def post_detail(request,last,bd,bt,ed,et): username = request.user user = User.objects.get(username=username) u=UserExtension.objects.get(user=user) np={ 'name':u.user.username, 'p_k':u.personal_key, } s='/dashboard/'+np['p_k']+ '/'+last+ '/'+bd+ '/'+bt+ '/'+ed+ '/'+et return HttpResponseRedirect(s) ``` 定義post_detail方法: 以修飾子"@"執行login_required,使此方法承襲使用者已經登入成功的資料 使用者會輸入帳號密碼(在request中)以及其他篩選資訊 將request中的user屬性給予變數username 再找username=資料庫User資料中username屬性的物件,給予變數user 再找user=資料庫UserExtension資料中user屬性的物件,給予變數u 創建json資料np並給予初始資料: * 'name'為u(UserExtension物件)中user屬性中的username屬性 * 'p_k'為u中的personal_key屬性 最後重新導向至/dashboard/<p_k>/<last>/<bd>/<bt>/<ed>/<et> > 此方法只會在背景執行,用p_k用以確認是否為假冒登錄者token的冒名資料盜取 --- ```python @login_required def dashboard(request,pk,last,bd,bt,ed,et): print("=======>dashboard okay!") compare_username = request.user user = User.objects.get(username=compare_username) u=UserExtension.objects.get(user=user) u_list, p_list, a_list, ad_list={},{},{},{} pn,an,adn=0,0,0 if(UserExtension.objects.filter(personal_key=pk,user__username=compare_username).first()):#if exists() for p in u.projectdata.all(): #can use:filter get(just one) for a in p.aerobox.all(): #for ad in a.aeroboxdata.all(): ad=a.aeroboxdata.last() adp=a.aeroboxdata.filter(rh=90)#QuerySet #print(":::::",bd,bt,'/',str(ad.time).split()[0],'/',str(ad.time).split()[1][0:5]) #print(";;;;;",bd>str(ad.time).split()[0]) bd=datetime.strptime(bd+bt, '%Y-%m-%d%H:%M') print(bd,'//',ad.time) bd.astimezone(pytz.utc) ad.time.astimezone(pytz.utc) '''try: print(ad.time,"====",bd,"====",ad.time>bd) #print(adp[0].time,"====",bd,"====",adp[0].time>bd) except IndexError: print("no adp")''' ad_list={ 'loading time':ad.time, 'lon':ad.lon, 'lat':ad.lat, 'pm':ad.pm, 'temp':ad.temp, 'rh':ad.rh, 'co2':ad.co2, } a_list['last:']=ad_list adn+=1 p_list['aerobox '+a.aerobox_id]=a_list an+=1 a_list={} adn=0 u_list['project"'+p.pj_name+'"']=p_list pn+=1 p_list={} an=0 return JsonResponse(u_list) ``` 定義dashboard方法: 以修飾子"@"執行login_required,意指使此方法承襲使用者已經登入成功的資料 使用跟post_detail方法同樣的方式找到對應的UserExtension物件 若使用request.user中的username以及前端傳來的p_k找的到相應的UserExtension,即該使用者是對的,就執行下方創建並回傳資料的程式: 創建json資料u_list,在經過特定篩選條件(最後筆數、開始與結束時間)後添加資料進入,以上述之資料結構回傳Json資料u_list > 此頁面以Json資料的形式回傳篩選後該使用者所擁有的資料,可再經過前端的渲染 --- ## 3. bot app結構討論 ### 3.0 bot.telegram的內部結構 * params.py >儲存TELEGRAM_URL="https://api.telegram.org/bot" 以及敏感資料:bot的{TOKEN} * api_models.py * TelegramObject類別:含有json_deserializer方法 * Update類別:繼承TelegramObject,有update_id、message、callback_query、channel_post屬性 * Message類別:繼承TelegramObject,有message_id=、from_user、date、chat、forward_from、forward_from_chat、reply_to_message、text、forward_from_message_id、forward_date、photo屬性 * ...Chat、User、CallbackQuery類別 >實作telegram官方的資料型態 * api_method.py * post(data)方法,requests.post到{TELEGRAM_URL}{TOKEN}/sendMessage * post_photo(data)方法,requests.post到{TELEGRAM_URL}{TOKEN}/sendPhoto * send_message(chat_id,text)方法,建立json檔案data, 其中"chat_id": chat_id, "text": text, "parse_mode": "Markdown" 並post(data) * send_photo(chat_id,photo_id)方法,建立json檔案data, 其中"chat_id": chat_id, "photo": photo_id 並post_photo(data) * ...send_inline_keyboard、send_keyboard、send_html_message方法 ### 3.1 bot的urls.py ```python from django.urls import path from .views import webhook from .telegram.params import * app_name = 'telegram_bot' urlpatterns = [ path(f'{TOKEN}/',webhook), # path(f'{TOKEN}/',webhook,name='web_hook'), ] ``` 從django.urls引用path 從views引用webhook方法 從.telegram.params引用所有物件 最後指定主urls檔案的/telegram_bot後接續{TOKEN} 此為telegram訊息傳入的urls ### 3.2 bot的models.py ```python from django.db import models from django.contrib.auth import get_user_model import hashlib import os User = get_user_model() class TelegramChat(models.Model): chat_id = models.CharField(max_length=50,null=False,blank=False,unique=True) ``` 引入models模式 從django.contrib.auth引入get_user_model 並用此方法傳遞使用者信息給變數User 最後創建類別TelegramChat,內含chat_id (CharField) ### 3.3 bot的views.py ```python from django.shortcuts import render from django.http import HttpResponse, JsonResponse from .telegram.params import * from .telegram.api_models import Update from .telegram.api_method import send_message, send_photo#and orters from .models import TelegramChat import requests import json def webhook(request): if request.method == "POST": print('hey bro!') j_data = json.loads(request.body.decode()) u_data = Update.json_deserializer(j_data) print('update:',u_data) if u_data != None: if u_data.message.text == '/wantjson': print('/wantjson') return_json={ 'test':'this is test', 'num':123, 'face':':)', } send_message(u_data.message.chat_belong_to.id,return_json) if u_data.message.text == '/get_storyweb': print('/get_storyweb') print('u_data.message.photo ',u_data.message.photo) send_message(u_data.message.chat_belong_to.id,'https://106601015.pythonanywhere.com') if u_data.message.photo != None: print('/get_pictest') print('u_data.message.photo ',u_data.message.photo) send_photo(u_data.message.chat_belong_to.id, u_data.message.photo[0]['file_id']) return HttpResponse('ok') else: return HttpResponse("This page is for telegram post, but you are getting!") ``` 從django.shortcuts引入render 從django.http引入HttpResponse,JsonResponse 從.telegram.params引入所有物件 從.telegram.api_models引入Update類別 從.telegram.api_method引入send_message,send_photo方法 從.models引入TelegramChat類別 引入基本的requests,json 定義webhook方法: 若request.method是POST(即使用者有訊息傳給bot) 則以json.loads方法解析request.body.decode,傳給j_data變數 再以Update類別的json_deserializer方法解析j_data,傳給u_data變數 若u_data(Update型態)不是空的,則將u_data.message.text進行辨識 * 若為"/wantjson": 定義json檔案return_json={'test':'this is test','num':123,'face':':)',} 使用send_message方法傳送(傳入參數u_data.message.chat_belong_to.id及return_json) ==>使用者即可收到return_json * 若為"/get_storyweb": 使用send_message方法傳送(傳入參數u_data.message.chat_belong_to.id,'https://106601015.pythonanywhere.com') ==>使用者即可收到"https://106601015.pythonanywhere.com" * 若u_data.message.photo非空(即使用者傳照片過來): 使用send_photo方法傳送(u_data.message.chat_belong_to.id, u_data.message.photo[0]['file_id']) ==>使用者收到本身原本傳過來的照片 --------- 以上為後端大致上的整體架構 * Django基礎簡易網站架設 * Django架構分部功能控制並了解機制 * 使用者登入功能 * 資料存取權分流 * 資安-csrf網路攻擊防患 * 資安-冒名盜取資料防患(pk加密) * personal_key加密方法機制 * git branch團隊協作 * 資料庫migrations在更改models後的更新 (python manage.py migrate --run-syncdb) * telegram的測試性實作 https://api.telegram.org/bot1220888644:AAH3nfw22d0cFYXI0KPqCugyUCoruwpQcwU/setWebhook?url=https://3a255e04783d.ngrok.io/telegram_bot/1220888644:AAH3nfw22d0cFYXI0KPqCugyUCoruwpQcwU/