Если нужно на коленке получить быстро админку, где фронтендом будет 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
на нижеследующий код, который будет отвечать за авторизацию, за выход из админки и прочее: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
на вот такой код: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)
norguhtar
24.11.2019 21:57Стоит добавить в проект webargs и marshmallow. С ними делать REST под flask прям хорошо и прекрасно.
pcdesign Автор
24.11.2019 22:16Спасибо за коммент. А вроде есть reqparse в flask_restful.
https://flask-restful.readthedocs.io/en/latest/api.html#module-reqparse
Нужен ли webargs?
А по-поводу marshmallow, я к нему присматриваюсь. И раз вы здесь его упомянули хотелось бы спросить: у вас есть позитивный опыт использования? Можете что-нибудь про него рассказать хорошее?norguhtar
25.11.2019 05:31webargs позволяет задавать параметры к запросу декоратором, что с моей точки зрения удобнее. Плюс он всеядный, если специально не указывать из какого источника брать ему можно присылать в любом виде, т.е. и query params и form-data и json. Он все обработает одинаково, главное чтобы имена совпадали.
Дополнительно он имеет отличную интеграцию с marshmallow, что позволяет прям объекты напрямую из запроса доставать.
Marshmallow я использую и это единственный на данный момент сериализатор под python который нормально из PostgreSQL жрет нативные uuid. Дополнительно там есть слой совместимости с sqlalchemy. Который мне правда не актуален, я использую PonyORM.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)
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 если требуется так же можно его довесить.pawnhearts
25.11.2019 07:55Так это код из моей библиотеки. А в самом проекте будет просто
async def test_handler(request, query: str, page: int=1): pass
Мне нравится использовать аннотации типов, этим pydantic больше нравится. В fastapi он аналогично используетсяnorguhtar
25.11.2019 08:02А что делать в случае если надо обрабатывать не только query?
pawnhearts
25.11.2019 08:03Обычно, в rest, data это данные модели, для которых тоже есть сериализатор. Ну или писать отдельный.
norguhtar
25.11.2019 08:11Ну в случае использования webargs это там сразу из коробки, чем оно и хорошо.
pcdesign Автор
25.11.2019 10:28norguhtar, спасибо за наводку.
Получается, что если мы хотим использовать 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
Верно? Я проверил, в принципе, работает.norguhtar
25.11.2019 10:36Да. Только еще стоит добавить:
@use_args({ 'username': fields.Str(required=True), 'password': fields.Str(required=True)}, locations=['json'])
В этом случае если параметры не обнаружены, вывалит ошибку по умолчанию. Туда можно вставить свой хендлер обработки и обрабатывать отсутствие параметров единожды. Там еще есть опция missing которая позволяет задавать умолчания, с ней надо учесть, что умолчания там задаются один раз при старте. А то я там положил раз datetime.now() :)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 в качестве исключения.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
И все. Обработка ошибок делается один раз и выводит то что надо.pcdesign Автор
25.11.2019 10:54Не, я к тому, что react выведет ошибку «Invalid username and password», а в случае с required=True выведет «Network error».
Вот что будет в браузере.norguhtar
25.11.2019 10:59А я вам и говорю, что поведение это изменяемое. И да 422 ошибка это не Network error. Она должна обрабатываться на стороне react особенно учитывая что подразумевается REST а там кодами отличными от 200 вообще-то пользоваться надо.
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
PerlPower
26.11.2019 10:31Потратил 3 часа на то, чтобы разобраться почему шаг в сторорону от стандартного туториала делает так что не работает ничего. Либо GuesserList выдает ошибку, либо самописный List вообще не делает запросов. Как отлаживать этого монстра вообще ума не приложу.
pcdesign Автор
26.11.2019 10:48Можно попробовать начать с simple:
codesandbox.io/s/github/marmelab/react-admin/tree/master/examples/simple
PerlPower
26.11.2019 11:04Можно было бы хотя бы попробовать сделать дебаггинг сетевых запросов, почему например тот же файл с JSON из туториала сохраненный на локальном сервере, сразу выдает ошибку что пользователи не найдены. При этом дебаг запросов есть только в FakeDataProvider, где он и так не нужен.
pcdesign Автор
26.11.2019 11:16Ну, да. Я на это тоже посмотрел, разбираться поленился и просто добавил axios.
Akuma
Админка за 0 минут — запустите любой Database-клиент. Я так некоторые проекты годами веду, очень удобно, все возможности.
Restfull-админка — это неудобно, потому что элементарные сущности и так можно добавлять в базу, а что-то более сложно все равно придется допиливать.
pawnhearts
Админкой пользуются не только люди, которые умеют с бд клиентом работать. Кроме того в таких приложениях часто присутствует какая-то логика помимо просто записей в базе и не на уровне бд.
zim32
Хранимые процедуры и вот вам логика
pawnhearts
Во многих проектах стараются их избегать. Потому что уйдет человек, которых их писал и это очень тяжело поддерживать. И с версионированием этого всего как-то непонятно. И удобнее когда вся логика в одном месте — в приложении.
dmitry-polushkin
Каким образом хранимые процедуры могут, например, отправить письма или сделать https запрос?
Sau
Легко, в MS SQL есть CLR-сборки, по сути вызов функций из DLL.
Source: Работаю с биллинговой системой, построенной подобным образом.
ffs
PerlPower
Требования этих людей к админкам порой настолько дикие, что приходится выкидывать весь фреймворк для для построения админок и писать ручками. Сомневаюсь что за 10 лет, когда я активно искал хорошее решение для PHP что-то поменялось. Я имею в виду условия когда вы не можете диктовать заказчику как должна выглядеть админка.
pawnhearts
Джанговская админка обычно всех устраивает и там есть всё что угодно из коробки или на pypi. И кастомизируется как угодно.
PerlPower
Окей, классический пример. Есть форма с допдауном. В зависимости от выбора дропдауна подгружается динамически кусок другой формы. Некоторые подформы формы имеют в своей основе сущности из БД, некоторые нет, некоторые имеют сущность в своей основе, но и коллбэки на отрисовку через внешний API. В зависимости от некоторых данных введенных в одной из подформ, автоматически заполняются поля в основной форме.
Опишите пожалуйста как вы будете делать такое чучело на любой технологии, правда интересно. Потому что мне еще не попадался достаточный уровень декларативности описания такой вот формы доставки, чтобы я мог не дописывать куски управления формой ручками. И если покажете хотя бы подход или хороший проект на гитхабе, то буду премного благодарен.
pawnhearts
Какой-то абстрактный пример. В админке django modelform можно кастомизировать, какие там должны быть поля и как их потом сохранить в бд Остальное накручивается на js.
PerlPower
Так вот хотелось бы без этой бесконечной накрутки на JS, вернее чтобы фреймворк сам ее делал. Пример не абстрактный — обычная форма доставки с расчетом стоимости разными ТК и заполнением ФИО учетки из адреса доставки.
pawnhearts
Ну стоимость можно сделать readonly полем и после сохранения высчитывать её. ФИО тоже после сохранения заполнять.