├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── app ├── core │ └── dbdriver.py ├── main.py ├── static │ ├── css │ │ ├── detail.css │ │ ├── main.css │ │ └── stylesheet.css │ ├── favicon.ico │ └── images │ │ ├── add.png │ │ ├── cheer.png │ │ ├── logo.png │ │ └── quotation_bg.png └── templates │ ├── _layout.html │ ├── detail.html │ └── main.html ├── docs ├── db_scheme.png └── screenshot.png └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | venv/ 2 | *.pyc 3 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM tiangolo/uwsgi-nginx-flask:python3.6 2 | 3 | COPY requirements.txt /tmp/ 4 | RUN pip install -r /tmp/requirements.txt 5 | 6 | COPY ./app /app -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 prevdev@gmail.com 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ASKHY [부탁하냥] 2 | 3 | [![Docker Automated build](https://img.shields.io/docker/automated/prev/askhy.svg)](https://hub.docker.com/r/prev/askhy/) 4 | 5 | Flask와 MySQL을 이용하고 docker를 이용해서 패키징 한 너무너무 간단한 웹 어플리케이션 6 | 7 | ![Screenshot](https://prev.kr/askhy/screenshot.png) 8 | 9 | 10 | ## How to run 11 | 12 | MySQL 컨테이너가 없다면 다음 명령어를 먼저 실행해야 합니다. 13 | (비밀번호나 컨테이너 이름 등은 취향껏 수정하되 아래 `askhy` 컨테이너 실행시 알맞게 설정해주어야 합니다) 14 | 15 | ```bash 16 | docker run -d \ 17 | -e MYSQL_ROOT_PASSWORD=root \ 18 | -e MYSQL_DATABASE=askhy \ 19 | --name mysql \ 20 | mysql:5.7 21 | ``` 22 | 23 | --- 24 | 25 | 그 뒤에 이 명령어를 통해 해당 어플리케이션의 이미지를 다운로드하고 컨테이너를 실행합니다. 26 | 27 | ```bash 28 | docker run -p 8080:80 \ 29 | --link mysql:mysql_host \ 30 | -e DATABASE_HOST=mysql_host \ 31 | -e DATABASE_USER=root \ 32 | -e DATABASE_PASS=root \ 33 | -e DATABASE_NAME=askhy \ 34 | --name askhy \ 35 | prev/askhy 36 | ``` 37 | --- 38 | 39 | 다음에 웹 브라우저를 열고 `localhost:8080` 에 접속하면 어플리케이션이 실행됩니다! 40 | 41 | 42 | 43 | ## Database Scheme 44 | 45 | 서비스 컨셉은 기본적인 게시판과 비슷하지만 게시글을 `부탁`이라는 이름으로 쓰며, 댓글을 `응원`이라는 이름을 사용합니다. MySQL 스키마는 아래와 같습니다. 46 | 47 | DB Scheme 48 | 49 | 50 | 51 | ## Pages 52 | 53 | - `GET /`: 메인 화면. 전체 `부탁`과 부탁 별 `응원 수`를 볼 수 있습니다. 54 | - `GET /ask/{$ask_id}`: 하나의 `부탁`에 대한 자세한 정보가 보여진다. 해당 부탁에 대한 모든 `응원`을 볼 수 있습니다. 55 | - `POST /ask`: 새로운 `부탁`을 등록하는 페이지 입니다. 56 | - `POST /ask/{$ask_id}/cheer`: 특정 `부탁`에 새로운 `응원`을 등록하는 페이지 입니다. 57 | 58 | 59 | 60 | ## Structure 61 | 62 | ``` 63 | . 64 | ├── Dockerfile 이미지 빌드용 스크립트 65 | ├── LICENSE 라이선스 66 | ├── README.md 67 | ├── app 68 | │ ├── core 앱 내부 코드 69 | │ │ └── dbdriver.py 데이터베이스(MYSQL) 연결 및 초기화용 드라이버 70 | │ ├── main.py URL route와 템플릿을 렌더링하는 메인 코드 71 | │ ├── static 프론트엔드 정적 파일 72 | │ │ ├── css 73 | │ │ │ ├── detail.css '부탁 상세보기'용 CSS 74 | │ │ │ ├── main.css '메인'용 CSS 75 | │ │ │ └── stylesheet.css 공통 CSS 76 | │ │ ├── favicon.ico 77 | │ │ └── images 78 | │ │ ├── add.png 79 | │ │ ├── cheer.png 80 | │ │ ├── logo.png 81 | │ │ └── quotation_bg.png 82 | │ └── templates 83 | │ ├── _layout.html 사이트 공통 템플릿 84 | │ ├── detail.html '부탁 상세보기'용 템플릿 85 | │ └── main.html '메인'용 CSS 86 | ├── docs 87 | └── requirements.txt Python Pacakage Dependency 88 | ``` 89 | 90 | 91 | 92 | ## Branches 93 | 94 | `ASKHY`에 기능을 추가하거나 변형 한 버전들이 존재합니다. 각각은 `branch`를 통해 관리되고 있습니다. 95 | 96 | | Branch | 설명 | 97 | | ------------------ | ---------------------------------------- | 98 | | master | MySQL을 사용하는 기본 웹 어플리케이션 | 99 | | arcus-combined | `arcus` 를 이용하여 주요 쿼리를 cache하여 성능 개선을 한 버전 | 100 | | redis-combined | `redis` 를 이용하여 주요 쿼리를 cache하여 성능 개선을 한 버전 | 101 | | 1.1 | `부탁` 별 `응원 수`에 추가적으로 하나의 IP 당 하나의 응원으로만 카운팅 한
`순수 응원 수`라는 지표도 함께 보여주는 어플리케이션 | 102 | | 1.1-arcus-combined | `1.1`의 기능에 `arcus`로 성능 개선을 한 버전 | 103 | | 1.1-redis-combined | `1.1`의 기능에 `redis`로 성능 개선을 한 버전 | 104 | 105 | 106 | 107 | 108 | ## External Libraries 109 | 110 | - [Flask](https://github.com/pallets/flask) 111 | - [PyMySQL](https://github.com/PyMySQL/PyMySQL) 112 | 113 | -------------------------------------------------------------------------------- /app/core/dbdriver.py: -------------------------------------------------------------------------------- 1 | import pymysql 2 | import os 3 | 4 | _db_instance = None 5 | def get_db() : 6 | """ Get database connection singleton object 7 | """ 8 | global _db_instance 9 | if _db_instance: return _db_instance 10 | else : 11 | _db_instance = pymysql.connect( 12 | host=os.environ.get('DATABASE_HOST', 'localhost'), 13 | user=os.environ.get('DATABASE_USER', 'root'), 14 | passwd=os.environ.get('DATABASE_PASS', ''), 15 | db=os.environ.get('DATABASE_NAME', 'test'), 16 | port=int(os.environ.get('DATABASE_PORT', 3306)), 17 | charset='utf8' 18 | ) 19 | 20 | return _db_instance 21 | 22 | 23 | def init_tables() : 24 | """ Init tables in this app 25 | """ 26 | with get_db().cursor() as cursor : 27 | try : 28 | cursor.execute("SELECT 1 FROM ask") 29 | except pymysql.err.ProgrammingError as e : 30 | from pymysql.constants import ER 31 | 32 | if e.args[0] == ER.NO_SUCH_TABLE : 33 | # Create tables 34 | cursor.execute(""" 35 | CREATE TABLE IF NOT EXISTS `ask` ( 36 | `id` int(11) NOT NULL AUTO_INCREMENT, 37 | `message` text COLLATE utf8mb4_unicode_ci NOT NULL, 38 | `ip_address` varchar(30) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', 39 | `register_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, 40 | PRIMARY KEY (`id`) 41 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci AUTO_INCREMENT=1 ; 42 | 43 | CREATE TABLE IF NOT EXISTS `cheer` ( 44 | `id` int(11) NOT NULL AUTO_INCREMENT, 45 | `ask_id` int(11) NOT NULL, 46 | `message` text COLLATE utf8mb4_unicode_ci NOT NULL, 47 | `ip_address` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', 48 | `register_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, 49 | PRIMARY KEY (`id`), 50 | KEY `ask_id` (`ask_id`) 51 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci AUTO_INCREMENT=1 ; 52 | 53 | ALTER TABLE `cheer` 54 | ADD CONSTRAINT `cheer_ibfk_1` FOREIGN KEY (`ask_id`) REFERENCES `ask` (`id`) ON DELETE CASCADE ON UPDATE CASCADE; 55 | """) 56 | 57 | else : 58 | raise e 59 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /app/main.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, render_template, request, redirect 2 | import os 3 | 4 | from core.dbdriver import get_db, init_tables 5 | 6 | app = Flask(__name__) 7 | 8 | 9 | # Init tables in db 10 | init_tables() 11 | 12 | @app.route('/') 13 | def index(): 14 | """ Index page 15 | Show list of `asks`, and cheer count of each ask 16 | """ 17 | with get_db().cursor() as cursor : 18 | cursor.execute("SELECT *, (SELECT COUNT(*) FROM `cheer` WHERE ask_id = ask.id) AS cheer_cnt FROM `ask`") 19 | result = cursor.fetchall() 20 | 21 | return render_template('main.html', 22 | dataset=result, 23 | ) 24 | 25 | 26 | @app.route('/ask/', methods=['GET']) 27 | def view_ask(ask_id): 28 | """ Show detail of one `ask` 29 | See all cheers in this ask 30 | 31 | :param ask_id: Primary key of `ask` table 32 | """ 33 | conn = get_db() 34 | 35 | with conn.cursor() as cursor : 36 | cursor.execute("SELECT * FROM `ask` WHERE id = %s", (ask_id, )) 37 | row = cursor.fetchone() 38 | 39 | cursor.execute("SELECT * FROM `cheer` WHERE ask_id = %s", (ask_id, )) 40 | rows2 = cursor.fetchall() 41 | 42 | return render_template('detail.html', 43 | id=row[0], 44 | message=row[1], 45 | ip_address=row[2], 46 | register_time=row[3], 47 | current_url=request.url, 48 | cheers=rows2, 49 | ) 50 | 51 | 52 | @app.route('/ask', methods=['POST']) 53 | def add_ask(): 54 | """ Add new ask 55 | 56 | :post-param message: Message of `ask` 57 | """ 58 | conn = get_db() 59 | message = request.form.get('message') 60 | 61 | with conn.cursor() as cursor : 62 | sql = "INSERT INTO `ask` (`message`, `ip_address`) VALUES (%s, %s)" 63 | r = cursor.execute(sql, (message, request.remote_addr)) 64 | 65 | id = conn.insert_id() 66 | conn.commit() 67 | 68 | return redirect("/#a" + str(id)) 69 | 70 | 71 | 72 | @app.route('/ask//cheer', methods=['POST']) 73 | def add_cheer(ask_id): 74 | """ Add new cheer to ask 75 | 76 | :param ask_id: Primary key of `ask` table 77 | :post-param message: Message of `cheer` 78 | """ 79 | conn = get_db() 80 | message = request.form.get('message') 81 | 82 | with conn.cursor() as cursor : 83 | sql = "INSERT INTO `cheer` (`ask_id`, `message`, `ip_address`) VALUES (%s, %s, %s)" 84 | r = cursor.execute(sql, (ask_id, message, request.remote_addr)) 85 | 86 | conn.commit() 87 | 88 | redirect_url = request.form.get('back', '/#c' + str(ask_id)) 89 | return redirect(redirect_url) 90 | 91 | 92 | 93 | @app.template_filter() 94 | def hide_ip_address(ip_address): 95 | """ 96 | Template filter: 97 | Hide last sections of IP address 98 | 99 | ex) 65.3.12.4 -> 65.3.*.* 100 | """ 101 | if not ip_address : return "" 102 | else : 103 | ipa = ip_address.split(".") 104 | return "%s.%s.*.*" % (ipa[0], ipa[1]) 105 | 106 | 107 | 108 | if __name__ == '__main__': 109 | app.run( 110 | host='0.0.0.0', 111 | debug=True, 112 | port=os.environ.get('APP_PORT', 8080) 113 | ) 114 | -------------------------------------------------------------------------------- /app/static/css/detail.css: -------------------------------------------------------------------------------- 1 | @charset "utf-8"; 2 | 3 | #content{ 4 | padding-bottom: 20px; 5 | } 6 | 7 | article{ 8 | margin: 20px 0; 9 | background-color: white; 10 | border-radius: 10px; 11 | } 12 | 13 | article h1{ 14 | margin: 0; 15 | padding: 35px 0 5px; 16 | text-align: center; 17 | font-size: 30px; 18 | } 19 | 20 | article > div{ 21 | text-align: center; 22 | } 23 | 24 | article .sub{ color: #666 } 25 | 26 | article .count-info{ 27 | padding: 9px 0 6px; 28 | border: none; 29 | outline: none; 30 | border-radius: 0; 31 | background-color: #16447E; 32 | color: white; 33 | font-size: 15px; 34 | 35 | width: 400px; 36 | margin: 10px auto 15px; 37 | } 38 | 39 | 40 | article ul{ 41 | padding-bottom: 30px; 42 | list-style: none; 43 | margin: 10px 0 0; 44 | padding: 0; 45 | } 46 | 47 | article ul li{ 48 | padding: 12px 20px; 49 | border-top: 1px solid #eee; 50 | text-align: left; 51 | } 52 | 53 | article ul li h4{ 54 | margin: 0; 55 | padding: 3px 0; 56 | } 57 | 58 | article ul li .content{ 59 | } 60 | 61 | article ul li .time{ 62 | color: #aaa; 63 | font-size: 13px; 64 | padding-top: 2px; 65 | } -------------------------------------------------------------------------------- /app/static/css/main.css: -------------------------------------------------------------------------------- 1 | @charset "utf-8"; 2 | 3 | /* main */ 4 | ul#asks{ 5 | list-style: none; 6 | margin: 0; 7 | padding: 0; 8 | } 9 | 10 | ul#asks li{ 11 | position: relative; 12 | height: 130px; 13 | margin-bottom: 20px; 14 | background-color: white; 15 | background-image: url('/static/images/quotation_bg.png'); 16 | background-size: 100% auto; 17 | } 18 | ul#asks li .highlight{ 19 | position: absolute; 20 | width: 100%; 21 | height: 100%; 22 | background-color: rgba(255, 141, 0, 0.5); 23 | display: none; 24 | } 25 | 26 | 27 | ul#asks li h4{ 28 | position: absolute; 29 | top: 38px; 30 | width: 100%; 31 | margin: 0; 32 | padding: 0; 33 | font-weight: bold; 34 | font-size: 24px; 35 | text-align: center; 36 | } 37 | 38 | ul#asks li .time{ 39 | position: absolute; 40 | color: #aaa; 41 | font-size: 13px; 42 | bottom: 10px; 43 | left: 10px; 44 | } 45 | 46 | 47 | 48 | ul#asks li .btn-container{ 49 | position: absolute; 50 | bottom: 10px; 51 | width: 240px; 52 | left: 50%; 53 | margin-left: -115px; 54 | } 55 | 56 | ul#asks li button.cheer-btn{ 57 | width: 200px; 58 | } 59 | ul#asks li button.cheer-btn .highlight{ 60 | width: 200px; 61 | height: 34px; 62 | top: 0; 63 | } 64 | 65 | ul#asks li a.look-inside{ 66 | display: inline-block; 67 | 68 | width: 34px; 69 | height: 34px; 70 | text-align: center; 71 | vertical-align: middle; 72 | border: none; 73 | outline: none; 74 | border-radius: 0; 75 | vertical-align: top; 76 | background-color: #eee; 77 | transition: all 0.5s; 78 | text-decoration: none; 79 | color: black; 80 | } 81 | ul#asks li a.look-inside:hover{ background-color: #ddd } 82 | 83 | ul#asks li a.look-inside i{ margin-top: 8px } 84 | 85 | -------------------------------------------------------------------------------- /app/static/css/stylesheet.css: -------------------------------------------------------------------------------- 1 | @charset "utf-8"; 2 | 3 | /* normalize.css */ 4 | article,aside,details,figcaption,figure,footer,header,hgroup,main,nav,section,summary{display:block;} 5 | audio,canvas,video{display:inline-block;} 6 | audio:not([controls]){display:none;height:0;} 7 | [hidden]{display:none;} 8 | html{background:#fff;color:#000;font-family:sans-serif;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%;} 9 | a:focus{outline:thin dotted;} 10 | a:active,a:hover{outline:0;} 11 | h1{font-size:2em;margin:.67em 0;} 12 | abbr[title]{border-bottom:1px dotted;} 13 | b,strong{font-weight:700;} 14 | dfn{font-style:italic;} 15 | hr{-moz-box-sizing:content-box;box-sizing:content-box;height:0;} 16 | mark{background:#ff0;color:#000;} 17 | /*code,kbd,pre,samp{font-family:monospace, serif;font-size:1em;}*/ 18 | pre{white-space:pre-wrap;} 19 | q{quotes:\201C \201D \2018 \2019;} 20 | small{font-size:80%;} 21 | sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline;} 22 | sup{top:-.5em;} 23 | sub{bottom:-.25em;} 24 | img{border:0;} 25 | svg:not(:root){overflow:hidden;} 26 | fieldset{border:1px solid #c0c0c0;margin:0 2px;padding:.35em .625em .75em;} 27 | button,input,select,textarea{font-family:inherit;font-size:100%;margin:0;} 28 | button,input{line-height:normal;} 29 | button,select{text-transform:none;} 30 | button,html input[type=button],/* 1 */ 31 | input[type=reset],input[type=submit]{-webkit-appearance:button;cursor:pointer;} 32 | button[disabled],html input[disabled]{cursor:default;} 33 | input[type=checkbox],input[type=radio]{box-sizing:border-box;padding:0;} 34 | input[type=search]{-webkit-appearance:textfield;-moz-box-sizing:content-box;-webkit-box-sizing:content-box;box-sizing:content-box;} 35 | input[type=search]::-webkit-search-cancel-button,input[type=search]::-webkit-search-decoration{-webkit-appearance:none;} 36 | textarea{overflow:auto;vertical-align:top;} 37 | table{border-collapse:collapse;border-spacing:0;} 38 | body,figure{margin:0;} 39 | legend,button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0;} 40 | /* end of normalize.css */ 41 | 42 | 43 | a img{ border:none; outline:none } 44 | 45 | .clearfix{ *zoom:1 } 46 | .clearfix:after{ display:block; clear:both; content:"" } 47 | .fl{ float:left } 48 | .fr{ float:right } 49 | .bold{ font-weight: bold } 50 | .hidden{ visibility: hidden } 51 | .hidden2{ display: none } 52 | 53 | html,body{ 54 | margin: 0; 55 | padding: 0; 56 | height: 100%; 57 | font-family: "Apple SD Gothic Neo",BlinkMacSystemFont,"나눔고딕",NanumGothic,"돋움",Dotum,Arial,Helvetica,sans-serif; 58 | background-color: #e3e4e8; 59 | } 60 | 61 | header,section#content{ 62 | width: 600px; 63 | margin: 0 auto; 64 | } 65 | 66 | header{ 67 | padding: 40px; 68 | 69 | } 70 | 71 | header h1#logo{ 72 | margin: 0 auto; 73 | padding: 0; 74 | width: 150px; 75 | height: 38px; 76 | background-image: url('../images/logo.png'); 77 | background-size: auto 38px; 78 | background-repeat: no-repeat; 79 | } 80 | 81 | 82 | /* Popup */ 83 | .popup{ 84 | display: none; 85 | position: fixed; 86 | top: 0; 87 | left: 0; 88 | width: 100%; 89 | height: 100%; 90 | z-index: 700; 91 | } 92 | .popup > .mask{ 93 | height: 100%; 94 | opacity: 0.6; 95 | filter: alpha(opacity=60); 96 | background-color: black; 97 | } 98 | .popup > .content{ 99 | position: absolute; 100 | top: 40%; 101 | left: 50%; 102 | width: 500px; 103 | padding: 20px; 104 | margin-left: -260px; 105 | box-sizing: border-box; 106 | background-color: white; 107 | } 108 | 109 | .popup > .content textarea{ 110 | width: calc(100% - 20px); 111 | margin: 15px 0 20px; 112 | padding: 10px; 113 | border: 1px solid #ddd; 114 | } 115 | 116 | .popup > .content button, 117 | .popup > .content input[type=submit] { 118 | width: 100%; 119 | } 120 | 121 | 122 | button.primary, 123 | input[type=submit].primary{ 124 | padding: 9px 0 6px; 125 | border: none; 126 | outline: none; 127 | border-radius: 0; 128 | background-color: #16447E; 129 | color: white; 130 | font-size: 15px; 131 | } 132 | 133 | 134 | button.add-btn{ 135 | width: 100%; 136 | padding: 20px 0; 137 | 138 | border: 2px solid #ccc; 139 | outline: none; 140 | border-radius: 0; 141 | 142 | font-size: 18px; 143 | color: #777; 144 | 145 | background: none; 146 | } 147 | 148 | -------------------------------------------------------------------------------- /app/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Prev/askhy/afc03411616dc58d5da935a199a78697809727fd/app/static/favicon.ico -------------------------------------------------------------------------------- /app/static/images/add.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Prev/askhy/afc03411616dc58d5da935a199a78697809727fd/app/static/images/add.png -------------------------------------------------------------------------------- /app/static/images/cheer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Prev/askhy/afc03411616dc58d5da935a199a78697809727fd/app/static/images/cheer.png -------------------------------------------------------------------------------- /app/static/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Prev/askhy/afc03411616dc58d5da935a199a78697809727fd/app/static/images/logo.png -------------------------------------------------------------------------------- /app/static/images/quotation_bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Prev/askhy/afc03411616dc58d5da935a199a78697809727fd/app/static/images/quotation_bg.png -------------------------------------------------------------------------------- /app/templates/_layout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 부탁하냥 5 | 6 | 7 | 8 | 9 | {% block meta %}{% endblock %} 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | {% block head %}{% endblock %} 20 | 21 | 22 |
23 | 24 |

25 | 부탁하냥 26 |

27 |
28 |
29 |
30 | {% block content %}{% endblock %} 31 |
32 | 33 | -------------------------------------------------------------------------------- /app/templates/detail.html: -------------------------------------------------------------------------------- 1 | {% extends "_layout.html" %} 2 | 3 | {% block head %} 4 | 5 | {% endblock %} 6 | 7 | {% block content %} 8 |
9 |

{{ message }}

10 |
11 |
{{ register_time.strftime("%Y년 %m월 %d일") }}
12 |
13 | 14 |
총 {{ cheers | length }}개의 응원
15 | 16 |
    17 | {% for id, ask_id, message, ip_address, register_time in cheers %} 18 |
  • 19 |

    {{ ip_address | hide_ip_address }}

    20 |
    {{ message }}
    21 |
    {{ register_time.strftime("%Y년 %m월 %d일") }}
    22 |
  • 23 | {% endfor %} 24 |
25 |
26 | 27 | 31 | 32 | 33 | 48 | {% endblock %} 49 | -------------------------------------------------------------------------------- /app/templates/main.html: -------------------------------------------------------------------------------- 1 | {% extends "_layout.html" %} 2 | 3 | {% block head %} 4 | 5 | 6 | 12 | {% endblock %} 13 | 14 | {% block content %} 15 |
    16 | {% for id, message, ip_address, register_time, cheer_cnt in dataset %} 17 |
  • 18 |
    19 |

    {{ message }}

    20 |
    {{ register_time.strftime("%Y년 %m월 %d일") }}
    21 | 22 |
    23 | 27 | 28 | 29 | 30 |
    31 |
  • 32 | {% endfor %} 33 |
34 | 35 | 39 | 40 | 41 | 55 | 56 | 57 | 71 | 72 | 92 | {% endblock %} 93 | -------------------------------------------------------------------------------- /docs/db_scheme.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Prev/askhy/afc03411616dc58d5da935a199a78697809727fd/docs/db_scheme.png -------------------------------------------------------------------------------- /docs/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Prev/askhy/afc03411616dc58d5da935a199a78697809727fd/docs/screenshot.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Flask>=0.10 2 | pymysql>=0.7 --------------------------------------------------------------------------------