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


    Привет, Хабрахабр! Готовое архитектурное решение для мобильных устройств, включая 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) — цифре, обозначающей порядковый номер меню, на котором пользователь «застрял», значит, вернуться надо на индексную позицию ноль (1-1=0). И еще раз: индекс — какое меню предпоследнее, позиция пользователя — какое меню сейчас. То, как вы отображаете меню и где вы его храните, — дело вашего приложения, но получение позиции — уже работа фреймворка.

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

    Код на 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")
            }
        }
    


    Спасибо


    Надеюсь, что вы не только оценили мой подход к описанию проделанной работы и поток мыслей в общем, но и проявили более глубокий интерес. А если вас увлекло полностью, буду рад ответить на ваши вопросы или помочь с разработкой со своей стороны.
    Метки:
    Поделиться публикацией
    Похожие публикации
    Комментарии 1
    • 0
      А на скриншотах расписание из родного ГУТ :)

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