Если нужно на коленке получить быстро админку, где фронтендом будет react-admin, а бэкендом Flask-RESTful api, то ниже минимальный код в несколько десятков строк, чтобы это реализовать.

Бэкенд Flask-RESTful api


Сам код состоит из одного файла main.py:

from flask import Flask, request
from flask_restful import Resource,  Api
from flask_jwt_extended import JWTManager
from flask_jwt_extended import create_access_token, jwt_required
from flask_cors import CORS

app = Flask(__name__)

app.config['JWT_SECRET_KEY'] = 'my_cool_secret'
jwt = JWTManager(app)
CORS(app)
api = Api(app)


class UserLogin(Resource):
    def post(self):
        username = request.get_json()['username']
        password = request.get_json()['password']
        if username == 'admin' and password == 'habr':
            access_token = create_access_token(identity={
                'role': 'admin',
            }, expires_delta=False)
            result = {'token': access_token}
            return result
        return {'error': 'Invalid username and password'}


class ProtectArea(Resource):
    @jwt_required
    def get(self):
        return {'answer': 42}


api.add_resource(UserLogin, '/api/login/')
api.add_resource(ProtectArea, '/api/protect-area/')

if __name__ == '__main__':
    app.run(debug=True, host='0.0.0.0')

Пробежимся по коду:

  • Все взаимодействие с внешним миром наш бэкэнд будет осуществлять только посредством RESTful api, даже авторизация в админке тоже через него. Для этого у flask есть удобный модуль: Flask-RESTful api
  • Модуль flask_jwt_extended нам послужит для защиты тех роутов, доступ к которым можно получить только после авторизации. Ничего сакрального тут нет, просто в заголовок (header) к каждому http запросу будет добавляться токен jwt ( JSON Web Token), по которому наше приложение будет понимать, что юзер авторизован.
    В коде выше видно, что используется декоратор @jwt_required для этих целей. Можно его добавлять в те маршруты API, которые должны быть защищены.
  • Без flask_cors мы получим следующую ошибку:
    Access to XMLHttpRequest at 'http://localhost:5000/api/login/' from origin 'http://localhost:3000' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.
    Подробнее о CORS здесь.

Поставим все необходимые библиотеки и запустим код командой:

python main.py

Как видно, я захардкодил логин и пароль к админке: admin / habr.

После того как flask стартанул, можно проверить его работоспособность с помощью curl:

curl -X POST -H "Content-Type: application/json" -d '{"username": "admin", "password": "habr"}' localhost:5000/api/login/

Если такой результат:

{
  "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIU...."
}

Значит все правильно и можно двигаться к фронту.

Фронтэнд react-admin


Мне понравился react-admin. Здесь документация, а тут демо версия:
https://marmelab.com/react-admin-demo/#/login
Логин: demo
Пароль: demo

Чтобы нам получить такую же админку, как в демке, выполняем следующие команды:


git clone https://github.com/marmelab/react-admin.git && cd react-admin && make install   
yarn add axios
make build
make run-demo

Теперь надо ее научить взаимодействовать с нашим бэкендом.

Для этого заменим содержимое файла admin/examples/demo/src/authProvider.js на нижеследующий код, который будет отвечать за авторизацию, за выход из админки и прочее:

admin/examples/demo/src/authProvider.js
import { AUTH_LOGIN, AUTH_LOGOUT, AUTH_ERROR, AUTH_CHECK, AUTH_GET_PERMISSIONS } from 'react-admin';
import axios from 'axios';
import decodeJwt from 'jwt-decode';

export default (type, params) => {

  if (type === AUTH_LOGIN) {
    const { username, password } = params;
    let data = JSON.stringify({ username, password });

    return axios.post('http://localhost:5000/api/login/', data, {
      headers: {
        'Content-Type': 'application/json',
      }
    }).then(res => {
      if (res.data.error || res.status !== 200) {
        throw new Error(res.data.error);
      }
      else {
        const token = res.data.token;
        const decodedToken = decodeJwt(token);
        const role = decodedToken.identity.role;
        localStorage.setItem('token', token);
        localStorage.setItem('role', role);
        return Promise.resolve();
      }
    });
  }

  if (type === AUTH_LOGOUT) {
    localStorage.removeItem('token');
    localStorage.removeItem('role');
    return Promise.resolve();
  }

  if (type === AUTH_ERROR) {
    const { status } = params;
    if (status === 401 || status === 403) {
      localStorage.removeItem('token');
      localStorage.removeItem('role');
      return Promise.reject();
    }
    return Promise.resolve();
  }

  if (type === AUTH_CHECK) {
    return localStorage.getItem('token') ? Promise.resolve() : Promise.reject({ redirectTo: '/login' });
  }

  if (type === AUTH_GET_PERMISSIONS) {
    const role = localStorage.getItem('role');
    return role ? Promise.resolve(role) : Promise.reject();
  }

};


И теперь для прикола обратимся к нашему бэкенду, к роуту: /api/protect-area/ и полученный результат воткнем на главной странице админки, там, где бородатые мужики.

Для этого заменим содержимое файла react-admin/examples/demo/src/dashboard/Welcome.js на вот такой код:

admin/examples/demo/src/dashboard/Welcome.js
import React, { useState, useEffect, useCallback } from 'react';
import axios from 'axios';
import Card from '@material-ui/core/Card';
import CardActions from '@material-ui/core/CardActions';
import CardContent from '@material-ui/core/CardContent';
import CardMedia from '@material-ui/core/CardMedia';
import Button from '@material-ui/core/Button';
import Typography from '@material-ui/core/Typography';
import HomeIcon from '@material-ui/icons/Home';
import CodeIcon from '@material-ui/icons/Code';
import { makeStyles } from '@material-ui/core/styles';
import { useTranslate } from 'react-admin';

const useStyles = makeStyles({
  media: {
    height: '18em',
  },
});

const mediaUrl = `https://marmelab.com/posters/beard-${parseInt(
  Math.random() * 10,
  10
) + 1}.jpeg`;

const Welcome = () => {

  const [state, setState] = useState({});
  const fetchFlask = useCallback(async () => {
    axios.defaults.headers.common['Authorization'] = 'Bearer ' + localStorage.getItem('token');
    await axios.get('http://localhost:5000/api/protect-area/').then(res => {
      const answer = res.data.answer;
      setState({ answer });
    });
  }, []);

  useEffect(() => {
    fetchFlask();
  }, []); // eslint-disable-line react-hooks/exhaustive-deps

  const translate = useTranslate();
  const classes = useStyles();


  return (
    <Card>
      <CardMedia image={mediaUrl} className={classes.media} />
      <CardContent>
        <Typography variant="h5" component="h2">
          {state.answer}
        </Typography>
        <Typography component="p">
          {translate('pos.dashboard.welcome.subtitle')}
        </Typography>
      </CardContent>
      <CardActions style={{ justifyContent: 'flex-end' }}>
        <Button href="https://marmelab.com/react-admin">
          <HomeIcon style={{ paddingRight: '0.5em' }} />
          {translate('pos.dashboard.welcome.aor_button')}
        </Button>
        <Button href="https://github.com/marmelab/react-admin/tree/master/examples/demo">
          <CodeIcon style={{ paddingRight: '0.5em' }} />
          {translate('pos.dashboard.welcome.demo_button')}
        </Button>
      </CardActions>
    </Card>
  );
};

export default Welcome;


Зайдем на адрес:

localhost:3000
Авторизуемся, введя логин / пасс: admin / habr

И если все норм, то увидим 42 в заголовке на главной странице.

Типа вот так:



Дополнительно


  • Помимо Flask-RESTful есть еще Flask-RESTplus, тут можно глянуть обсуждение, что лучше или хуже
  • Можно написать админку на фронте, дальше запустить: npm run build — получатся готовые статические файлы, которые flask может отдавать просто как темплейт. Подробнее здесь. И таким образом можно избавится от необходимости держать запущенным веб-сервер, отвечающий за react.

Комментарии (36)


  1. Akuma
    24.11.2019 15:34
    +5

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

    Restfull-админка — это неудобно, потому что элементарные сущности и так можно добавлять в базу, а что-то более сложно все равно придется допиливать.


    1. pawnhearts
      24.11.2019 17:23

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


      1. zim32
        25.11.2019 00:59

        Хранимые процедуры и вот вам логика


        1. pawnhearts
          25.11.2019 06:18

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


        1. dmitry-polushkin
          25.11.2019 08:11

          Каким образом хранимые процедуры могут, например, отправить письма или сделать https запрос?


          1. Sau
            25.11.2019 12:24

            Легко, в MS SQL есть CLR-сборки, по сути вызов функций из DLL.
            Source: Работаю с биллинговой системой, построенной подобным образом.


          1. ffs
            25.11.2019 20:51
            -1

            Ты же просто хранимая процедура, имитация ЯП. Разве может хранимая процедура отправить письмо или сделать http-запрос?


      1. PerlPower
        25.11.2019 05:44

        Требования этих людей к админкам порой настолько дикие, что приходится выкидывать весь фреймворк для для построения админок и писать ручками. Сомневаюсь что за 10 лет, когда я активно искал хорошее решение для PHP что-то поменялось. Я имею в виду условия когда вы не можете диктовать заказчику как должна выглядеть админка.


        1. pawnhearts
          25.11.2019 06:19

          Джанговская админка обычно всех устраивает и там есть всё что угодно из коробки или на pypi. И кастомизируется как угодно.


          1. PerlPower
            25.11.2019 06:58

            Окей, классический пример. Есть форма с допдауном. В зависимости от выбора дропдауна подгружается динамически кусок другой формы. Некоторые подформы формы имеют в своей основе сущности из БД, некоторые нет, некоторые имеют сущность в своей основе, но и коллбэки на отрисовку через внешний API. В зависимости от некоторых данных введенных в одной из подформ, автоматически заполняются поля в основной форме.

            Опишите пожалуйста как вы будете делать такое чучело на любой технологии, правда интересно. Потому что мне еще не попадался достаточный уровень декларативности описания такой вот формы доставки, чтобы я мог не дописывать куски управления формой ручками. И если покажете хотя бы подход или хороший проект на гитхабе, то буду премного благодарен.


            1. pawnhearts
              25.11.2019 07:04

              Какой-то абстрактный пример. В админке django modelform можно кастомизировать, какие там должны быть поля и как их потом сохранить в бд Остальное накручивается на js.


              1. PerlPower
                25.11.2019 07:18

                Так вот хотелось бы без этой бесконечной накрутки на JS, вернее чтобы фреймворк сам ее делал. Пример не абстрактный — обычная форма доставки с расчетом стоимости разными ТК и заполнением ФИО учетки из адреса доставки.


                1. pawnhearts
                  25.11.2019 07:52

                  Ну стоимость можно сделать readonly полем и после сохранения высчитывать её. ФИО тоже после сохранения заполнять.


  1. norguhtar
    24.11.2019 21:57

    Стоит добавить в проект webargs и marshmallow. С ними делать REST под flask прям хорошо и прекрасно.


    1. pcdesign Автор
      24.11.2019 22:16

      Спасибо за коммент. А вроде есть reqparse в flask_restful.
      https://flask-restful.readthedocs.io/en/latest/api.html#module-reqparse
      Нужен ли webargs?

      А по-поводу marshmallow, я к нему присматриваюсь. И раз вы здесь его упомянули хотелось бы спросить: у вас есть позитивный опыт использования? Можете что-нибудь про него рассказать хорошее?


      1. norguhtar
        25.11.2019 05:31

        webargs позволяет задавать параметры к запросу декоратором, что с моей точки зрения удобнее. Плюс он всеядный, если специально не указывать из какого источника брать ему можно присылать в любом виде, т.е. и query params и form-data и json. Он все обработает одинаково, главное чтобы имена совпадали.
        Дополнительно он имеет отличную интеграцию с marshmallow, что позволяет прям объекты напрямую из запроса доставать.
        Marshmallow я использую и это единственный на данный момент сериализатор под python который нормально из PostgreSQL жрет нативные uuid. Дополнительно там есть слой совместимости с sqlalchemy. Который мне правда не актуален, я использую PonyORM.


        1. pawnhearts
          25.11.2019 06:10

          Мне больше нравится pydantic. У себя я превращаю аргументы функции в параметры запроса так:

          from pydantic import create_model
          def get_query_schema(handler):
              params = inspect.signature(handler).parameters
              query_params = {k: (p.annotation, p.default) for k, p in params.items() if k not in ('pk', 'request', 'self')}
              return create_model('query_schema', **query_params)
          

          это можно использовать потом в декораторе или middleware
          @web.middleware
          async def webapi_validate_query(request, handler):
              self = handler.__closure__[0].cell_contents.__self__
              if request.method not in ('GET', 'POST', 'PUT', 'DELETE'):
                  raise web.HTTPMethodNotAllowed(f'{request.method} not allowed')
              query = request.query.copy()
              if self.paginator:
                  self.paginator.get_page_from_query(query)
              if self.filter_class:
                  self.filter = self.filter_class(**request.query)
              validated_query = self.query_schema(**query.items()).dict()
              result = await handler(request, **request.match_info, **validated_query)
              return web.json_response(result, dumps=dumps)


          1. norguhtar
            25.11.2019 07:06

            Вот не лень писать столько кода? Смотрите как это выглядит в случае webargs

            @bp.route('/charge', methods=['POST'])
            @use_args({
                "account": fields.Int(required=True),
                "agent": fields.Int(required=True),
                "ts": fields.DateTime(),
                "unit": fields.Int(required=True),
                "service": fields.UUID(required=True),
                "amount": fields.Decimal(required=True),
                "count": fields.Decimal(missing=1),
                "note": fields.Str()
            })
            def add_charge(args):
                schema = ChargeMaSchema()
                if args.get('amount') < 0:
                    return abort(422)
                try:
                    new_charge = Charge(**args)
                    commit()
                    return schema.jsonify(new_charge)
                except:
                    abort(422)
            

            К примеру кусок кода из моего проекта. Аннотацию входных данных можно так же брать из схемы marshmallow.

            На abort дополнительно можно довесить обработчик, в webargs если требуется так же можно его довесить.


            1. pawnhearts
              25.11.2019 07:55

              Так это код из моей библиотеки. А в самом проекте будет просто
              async def test_handler(request, query: str, page: int=1): pass

              Мне нравится использовать аннотации типов, этим pydantic больше нравится. В fastapi он аналогично используется


              1. norguhtar
                25.11.2019 08:02

                А что делать в случае если надо обрабатывать не только query?


                1. pawnhearts
                  25.11.2019 08:03

                  Обычно, в rest, data это данные модели, для которых тоже есть сериализатор. Ну или писать отдельный.


                  1. norguhtar
                    25.11.2019 08:11

                    Ну в случае использования webargs это там сразу из коробки, чем оно и хорошо.


        1. pcdesign Автор
          25.11.2019 10:28

          norguhtar, спасибо за наводку.
          Получается, что если мы хотим использовать webargs в примере из статьи.
          То писать надо примерно так:

          from webargs.flaskparser import use_args                                                                                                                                 
          from webargs import fields    
          # Code ...
          class UserLogin(Resource):                                                                                                                                               
                @use_args({'username': fields.Str(),                                                                                                                                 
                           'password': fields.Str()}, locations=['json'])                                                                                                            
                def post(self, args):                                                                                                                                                
                    if args.get('username') == 'admin' and args.get('password') == 'habr': 
                       # Code
          
          

          Верно? Я проверил, в принципе, работает.


          1. norguhtar
            25.11.2019 10:36

            Да. Только еще стоит добавить:

              @use_args({
            'username': fields.Str(required=True),                                                                                                                                 
            'password': fields.Str(required=True)}, 
            locations=['json'])    
            


            В этом случае если параметры не обнаружены, вывалит ошибку по умолчанию. Туда можно вставить свой хендлер обработки и обрабатывать отсутствие параметров единожды. Там еще есть опция missing которая позволяет задавать умолчания, с ней надо учесть, что умолчания там задаются один раз при старте. А то я там положил раз datetime.now() :)


            1. pcdesign Автор
              25.11.2019 10:46

              Допустим удалим один или несколько параметров, то мы получаем в json

              {
                  "error": "Invalid username and password"
              }


              А если есть required=True, то получим
              <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">
              <title>422 Unprocessable Entity</title>
              <h1>Unprocessable Entity</h1>
              <p>The request was well-formed but was unable to be followed due to semantic errors.</p>
              


              Видимо, надо по ситуации все же решать. Либо как-то научить его отдавать json в качестве исключения.


              1. norguhtar
                25.11.2019 10:51

                Это отрабатывает глобальный хендлер. Его можно переопределить и это более правильный вариант обработки ошибок. Для flask webargs.readthedocs.io/en/latest/framework_support.html#flask
                Если хочется отдавать 200 то так
                webargs.readthedocs.io/en/latest/advanced.html#returning-http-400-responses

                И все. Обработка ошибок делается один раз и выводит то что надо.


                1. pcdesign Автор
                  25.11.2019 10:54

                  Не, я к тому, что react выведет ошибку «Invalid username and password», а в случае с required=True выведет «Network error».
                  Вот что будет в браузере.


                  1. norguhtar
                    25.11.2019 10:59

                    А я вам и говорю, что поведение это изменяемое. И да 422 ошибка это не Network error. Она должна обрабатываться на стороне react особенно учитывая что подразумевается REST а там кодами отличными от 200 вообще-то пользоваться надо.


                    1. pcdesign Автор
                      25.11.2019 11:00

                      norguhtar, понял, спасибо :)


      1. pawnhearts
        25.11.2019 06:15

        Просто годная библиотека для сериализации. Кстати, я тут сделал генераторы маршмаллов из моделей peewee и алхимии, может кому пригодится
        github.com/pawnhearts/aiorf/blob/master/aiorf/modelschema.py
        github.com/pawnhearts/aiorf/blob/master/aiorf/saschema.py


  1. PerlPower
    26.11.2019 10:31

    Потратил 3 часа на то, чтобы разобраться почему шаг в сторорону от стандартного туториала делает так что не работает ничего. Либо GuesserList выдает ошибку, либо самописный List вообще не делает запросов. Как отлаживать этого монстра вообще ума не приложу.


    1. pcdesign Автор
      26.11.2019 10:48

      Можно попробовать начать с simple:
      codesandbox.io/s/github/marmelab/react-admin/tree/master/examples/simple


      1. PerlPower
        26.11.2019 11:04

        Можно было бы хотя бы попробовать сделать дебаггинг сетевых запросов, почему например тот же файл с JSON из туториала сохраненный на локальном сервере, сразу выдает ошибку что пользователи не найдены. При этом дебаг запросов есть только в FakeDataProvider, где он и так не нужен.


        1. pcdesign Автор
          26.11.2019 11:16

          Ну, да. Я на это тоже посмотрел, разбираться поленился и просто добавил axios.


          1. PerlPower
            26.11.2019 22:10

            В итоге помогло добавление в ответ заголовка:

            "Access-Control-Allow-Origin: адрес.сайта.на.которомюадминка.com"

            Такое надо писать большими красными буквами, а ни в одном дефолтном провайдере это не написано.


            1. pcdesign Автор
              26.11.2019 22:19

              PerlPower, Благодарю!