python

Когда GitHub выстреливает вам в голову, создается новый фреймворк. Идея, концепция и реализация «Rut

  • среда, 12 апреля 2017 г. в 03:13:40
https://habrahabr.ru/post/326222/
  • Разработка мобильных приложений
  • Python
  • Open source
  • GitHub



Привет, Хабрахабр! Готовое архитектурное решение для мобильных устройств, включая iOS, Android, Telegram-bots, а также платформы, поддерживающие обработку http-запросов, выступающее в роли пет-проекта автора статьи, будет интересно желающим реализовать «карманное» расписание занятий для своих университетов и школ.

Содержание публикации:

  • Что предшествовало созданию фреймворка.
  • Проблемы программистов, которые решаются с «Rutetider».
  • Детали архитектурной структуры инструмента.
  • О компонентах, являющихся основным каркасом, и модулях, улучшающих разработку, а также разнообразные примеры.

Введение


Для того, чтобы внести свою лепту в сообщество open-source по большей части и в меньшей — чтобы решить проблему недоступности расписания занятий университета на мобильных устройствах (по правде говоря, доступности, но крайне неадаптивной и «долгой») — пришлось воспользоваться самой лучшей возможностью — написать Telegram-bot`а (если интересно — статья на Хабрахабре), а чтобы решить проблему не только для своего университета — небольшой фреймворк.


Было принято базировать фреймворк на первом решении, с теми же инструментами, что и для бота, но не исключать возможности разработки на платформах, напрямую поддерживающих целостность мобильных приложений, — iOS, Android, да и в общем-то на любых других платформах (веб-приложение с адаптивной версткой под телефоны, к примеру).

Проще говоря, определилось два вида доступа к функционалу — REST-API и Python-библиотека для программистов, использующих непосредственно Python.

А еще Rutetider


Это набор методов и инструментов, базирующихся на шаблонной последовательности, которые позволят создать, возможно, не гибкое, но безусловно рабочее приложение. В первую очередь — это решение «здесь и сейчас»; если главной целью стоит развитие — напишите все с нуля самостоятельно и не используйте фреймворк.

Еще одним позитивным моментом можно выделить доступную документацию, наполненную не только объяснениями работы, но и иллюстрациями и инструкциями, значительно ускоряющими понимание и разработку.

Архитектура фреймворка


Основной принцип


Как упоминалось выше, очень трудно, не имея большого опыта программирования в целом, определить правильную и красивую структуру, поэтому пришлось упереться во что-то шаблонное, но со своими плюсами — достаточно очевидное и рабочее.


Если говорить от лица пользователя, то ему будет необходимо пройти ряд экранов: выбор главной опции (возможность получить расписание) среди множества возможных других, факультета, курса, затем группы и непосредственно даты (также среди множества других полезных фич).

Из схемы должно быть видно, что самому программисту нужно «ловить» расположение пользователя и отображать необходимые меню с разным контентом, а также вести статистику, если стоит такое условие.


Подробнее о необходимых методах


Чтобы не отрываться от контекста, продолжим со знакомого — записывать позицию пользователя необходимо на платформах без возможности использования какого-нибудь локального хранилища (как, например, телефон пользователя), потому что кнопка «Вернуться назад» сама по себе не знает куда возвращаться, ей нужно «скормить» эту же позицию. Еще один пример — знать, какие все-таки данные вводит студент, чтобы потом определить по факультету и курсу группу, а по группе выбрать расписание на соответствующую дату.

Кроме того, программист может рассчитывать на удобную работу с датами на сегодняшний и завтрашний день, то есть присутствует возможность как внести точные и актуальные значения, так и получить.

Пока остановились на внесении данных, стоит упомянуть, что фреймворк располагает методами, готовыми помочь дополнительно структурировать информацию о парах в университетах – от аудитории и времени до данных преподавателя.

Держите пример добавления параметров лекций:

from rutetider import Timetable

timetable = Timetable(database_url)
timetable.add_lesson('IT', '3', 'PD-31', '18.10', 'Литература', 
                     '451', '2', 'Шевченко Т.Г.')
# params: faculty, course, group_name, lesson_date, lesson_title, 
#         lesson_classroom, lesson_order, lesson_teacher

Я все еще не понимаю, как это работает


Я постарался добавить немного модульности в инструменты, чтобы некоторые платформы могли не использовать ненужный функционал, но с обратной стороны «сковал» наручниками каждого желающего использовать «Rutetider» — наличие сервера (скорее всего) и базы данных.

Необходимость в создании базы данных вызвана тем, что у автора недостаточно ресурсов для обеспечения каждого свободным местом для его расписания и прочей ценной информации, поэтому программисту придется оформлять собственную PostgreSQL и поделиться ссылкой на доступ (к счастью, бесплатных возможностей много, про одну из них я рассказываю здесь).





А вот поиск сервера, возможно, кому-то и не потребуется, но точно будет необходимым тем, чей университет обновляет расписание каждый день или каждую неделю — в этом случае создание инструмента для внесения расписания посредством парсера, чтения CSV или любого удобного способа — обязательный пункт.

И здесь нам всем здорово повезло, потому что общество информационных технологий поддерживает разработчиков: Heroku Cloud Platform для Python, Java, Node.js и Firebase, Parse, Polljoy — iOS (автор не использовал большинство предложений; если у вас есть дополнения или замечания на этот счет — сообщите).

На какой функционал можно рассчитывать


Лекции и пары — компонент общей структуры, отвечающий за работу с обработкой занятий. Если пример с добавлением пар вы видели, то посмотрите их получение.

schedule = timetable.get_lessons('PD-31', '18.10')
# params: group_name, lesson_date

print(schedule)
# {'lessons': {
#           '3': {'lesson_teacher': 'Шевченко О.В.', 'lesson_classroom': 
#                 '451', 'lesson_order': '3', 'lesson_title': 'Литература'}, 
#           '1': {'lesson_teacher': 'Шульга О.С.', 'lesson_classroom': '118', 
#                 'lesson_order': '1', 'lesson_title': #'Математика'}, 
#           '2': {'lesson_teacher': 'Ковальчук Н.О.', 'lesson_classroom': '200', 
#                 'lesson_order': '2', 'lesson_title': #'Инженерия ПО'}}}

Подписка, но не на уведомления, что вполне может оказаться полезной фичей в будущем при актуальности фреймворка, а на получение расписания всего по одному клику.


По причине того, что архитектура обязывает нажимать несколько кнопок и видеть перед собой столько же экранов и что-то выбирать, данный функционал крайне полезен — пользователь должен один раз подписаться на определенную группу и больше ему не придется «париться».

Код на Swift
import UIKit

class ViewController: UIViewController {

    fileprivate let databaseURL = "postgres://nwritrny:VQJnfVmooh3S0TkAghEgA--YOxoaPJOR@stampy.db.elephantsql.com:5432/nwritrny"
    fileprivate let apiURL = "http://api.rutetiderframework.com"
    
    @IBAction func subscribeAction(_ sender: Any) {
        let headers = ["content-type": "application/x-www-form-urlencoded"]
        
        let postData = NSMutableData(data: "url=\(databaseURL)".data(using: .utf8)!)
        postData.append("&user_id=1251252".data(using: .utf8)!)
        postData.append("&group_name=PD-3431".data(using: .utf8)!)
        
        let request = NSMutableURLRequest(url: NSURL(string: "\(apiURL)/subscribers/add_subscriber")! as URL,
                                          cachePolicy: .useProtocolCachePolicy,
                                          timeoutInterval: 10.0)
        request.httpMethod = "PUT"
        request.allHTTPHeaderFields = headers
        request.httpBody = postData as Data
        
        let session = URLSession.shared
        let dataTask = session.dataTask(with: request as URLRequest, completionHandler: { (data, response, error) -> Void in
            if (error != nil) {
                print(error)
            } else {
                let httpResponse = response as? HTTPURLResponse
                print(httpResponse)
            }
        })
        
        dataTask.resume()
    }

    @IBAction func getSubscriptionInfoAction(_ sender: Any) {
    
        let headers = ["content-type": "application/x-www-form-urlencoded"]
        
        let postData = NSMutableData(data: "url=\(databaseURL)".data(using: .utf8)!)
        postData.append("&user_id=1251252".data(using: String.Encoding.utf8)!)
        
        let request = NSMutableURLRequest(url: NSURL(string: "\(apiURL)/subscribers/get_subscriber_group")! as URL,
                                          cachePolicy: .useProtocolCachePolicy,
                                          timeoutInterval: 10.0)
        request.httpMethod = "POST"
        request.allHTTPHeaderFields = headers
        request.httpBody = postData as Data
        
        let session = URLSession.shared
        let dataTask = session.dataTask(with: request as URLRequest, completionHandler: { (data, response, error) -> Void in
            if (error != nil) {
                print(error)
            } else if let jsonData = data {
                do {
                    let json = try JSONSerialization.jsonObject(with: jsonData) as? Dictionary<String, Any>
                    print(json?["group"])
                } catch let error{
                    print(error)
                }
            }
        })
        
        dataTask.resume()
    }
    
}


Текущие даты с возможностью внесения и получения расписания на сегодняшний и завтрашний день.

import requests
import json

api_url = 'http://api.rutetiderframework.com'

database_url = 'postgres://nwritrny:VQJnfVmooh3S0TkAghEgA--YOxoaPJOR@stampy.db.elephantsql.com:5432/nwritrny'
# Это тестовый параметр, в запросе должна быть ссылка на вашу рабочую базу данных

r = requests.post(api_url + '/currentdates/', data=json.dumps({
	'url': database_url}), headers={'content-type': 'application/json'})

print(r.status_code)
# 200
# Если вы работаете с компонентом впервые, вам необходимо проинициализировать необходимые таблицы, 
# то есть вызвать соответсвующий метод.

r = requests.put('http://api.rutetiderframework.com/currentdates/add_current_dates', data=json.dumps({
	'url': database_url,
	'today': '07.04',
	'tomorrow': '08.04'}), headers={'content-type': 'application/json'})

r = requests.post('http://api.rutetiderframework.com/currentdates/get_current_dates', data=json.dumps({
	'url': database_url}), headers={'content-type': 'application/json'})

print(r.json())
# {'dates': ['07.04', '08.04']}

Важным, но не менее сложным для начального понимания, пунктом является позиция пользователя — из-за невозможности использования встроенных или других удобных средств.


Например, если пользователь выбирает группу, то нам необходимо знать, какой выбор пользователь уже сделал (факультет и курс), а если он ошибся курсом — то среагировать на нажатие кнопки «Вернуться назад».

@bot.message_handler(func=lambda mess: 'Вернуться назад' == mess.text, content_types=['text'])
def handle_text(message):
    user_position = UserPosition(database_url).back_keyboard(str(message.chat.id))
    if user_position == 1:
        UserPosition(database_url).cancel_getting_started(str(message.chat.id))
        keyboard.main_menu(message)

    if user_position == 2:
        UserPosition(database_url).cancel_faculty(str(message.chat.id))
        keyboard.get_all_faculties(message)

    if user_position == 3:
        UserPosition(database_url).cancel_course(str(message.chat.id))
        faculty = UserPosition(database_url).verification(str(message.chat.id))
        if faculty != "Загальні підрозділи" and faculty != 'Заочне навчання':
            keyboard.stable_six_courses(message)

        if faculty == "Загальні підрозділи":
            keyboard.stable_one_course(message)

        if faculty == "Заочне навчання":
            keyboard.stable_three_courses(message)

    if user_position == 4:
        UserPosition(database_url).cancel_group(str(message.chat.id))
        faculty, course = UserPosition(database_url).get_faculty_and_course(str(message.chat.id))
        groups_list = Timetable(database_url).get_all_groups(faculty, course)
        groups_list.sort()
        keyboard.group_list_by_faculty_and_group(groups_list, message)

Возвращение на одно меню назад реализовывается немного сложнее, поэтому давайте разберем это на схеме.

Чтобы знать, какое меню необходимо пользователю, если он хочет вернуться назад, нам нужно воспользоваться методом «back_keyboard», который подскажет, на какой позиции остановился пользователь. Из схемы видно, что позиция равна единице (1) — цифре, обозначающей порядковый номер меню, на котором пользователь «застрял», значит, вернуться надо на индексную позицию ноль (один минус один равно ноль). И еще раз: индекс — какое меню предпоследнее, позиция пользователя — какое меню сейчас. То, как вы отображаете меню и где вы его храните, — дело вашего приложения, но получение позиции — уже работа фреймворка.

Заключительная часть архитектуры — статистика, здесь ничего сложного, но много полезного. Например, вы можете легко вести детальную статистику вашего приложения — записывать количество выбранного пользователями факультета, а потом с легкостью получать данную цифру и отображать в какую-нибудь админ-панель.

Код на Swift
func initializeDatabase() {
        let request = NSMutableURLRequest(url: NSURL(string: "\(apiURL)/statistics/")! as URL,
                                          cachePolicy: .useProtocolCachePolicy,
                                          timeoutInterval: 10.0)
        request.httpMethod = "POST"
        request.allHTTPHeaderFields = headers
        
        let session = URLSession.shared
        let dataTask = session.dataTask(with: request as URLRequest, completionHandler: callback)
        
        dataTask.resume()
    }
    
    func addStatistic() {

        let body = ["url": databaseURL, "user_id": "1251252", "point": "faculty", "date": "06.04.2017"]
        
        var jsonBody: Data?
        
        do {
            jsonBody = try JSONSerialization.data(withJSONObject: body)
        } catch  {
        }
        
        let request = NSMutableURLRequest(url: NSURL(string: "\(apiURL)/statistics/add_statistics")! as URL,
                                          cachePolicy: .useProtocolCachePolicy,
                                          timeoutInterval: 10.0)
        request.httpMethod = "PUT"
        request.allHTTPHeaderFields = headers
        request.httpBody = jsonBody
        
        let session = URLSession.shared
        let dataTask = session.dataTask(with: request as URLRequest, completionHandler: callback)
        
        dataTask.resume()
    }
    
    func getStatistic() {
        let body = ["url": databaseURL, "user_id": "1251252"]
        var jsonBody: Data?
        do {
            jsonBody = try JSONSerialization.data(withJSONObject: body)
        } catch  {
        }
        let request = NSMutableURLRequest(url: NSURL(string: "\(apiURL)/statistics/get_statistics_general")! as URL,
                                          cachePolicy: .useProtocolCachePolicy,
                                          timeoutInterval: 10.0)
        request.httpMethod = "POST"
        request.allHTTPHeaderFields = headers
        request.httpBody = jsonBody
        
        let session = URLSession.shared
        let dataTask = session.dataTask(with: request as URLRequest, completionHandler: callback)
        dataTask.resume()
    }
    
    func callback(_ data: Data?, _ resp: URLResponse?, _ error: Error?) {
        printResponse(resp, error: error)
        parseResponse(data)
    }
    
    func parseResponse(_ data: Data?) {
        if let jsonData = data {
            do {
                let json = try JSONSerialization.jsonObject(with: jsonData) as? Dictionary<String, Any>
                print(json ?? "json is nil")
            } catch let error{
                print(error)
            }
        }
    }
    
    func printResponse(_ response: URLResponse?, error: Error?)  {
        if (error != nil) {
            print(error!)
        } else {
            let httpResponse = response as? HTTPURLResponse
            print(httpResponse ?? "response is nil")
        }
    }


Спасибо


Надеюсь, что вы не только оценили мой подход к описанию проделанной работы и поток мыслей в общем, но и проявили более глубокий интерес. А если вас увлекло полностью, буду рад ответить на ваши вопросы или помочь с разработкой со своей стороны.