{"id":476455,"date":"2026-04-18T12:16:21","date_gmt":"2026-04-18T12:16:21","guid":{"rendered":"https:\/\/savepearlharbor.com\/?p=476455"},"modified":"-0001-11-30T00:00:00","modified_gmt":"-0001-11-29T21:00:00","slug":"","status":"publish","type":"post","link":"https:\/\/savepearlharbor.com\/?p=476455","title":{"rendered":"\u041a\u0430\u043a \u044f \u043f\u0435\u0440\u0435\u0441\u0442\u0430\u043b \u043a\u043e\u043f\u0438\u043f\u0430\u0441\u0442\u0438\u0442\u044c \u043e\u0434\u043d\u043e \u0438 \u0442\u043e \u0436\u0435 \u0432 \u043a\u0430\u0436\u0434\u043e\u043c Django-\u043f\u0440\u043e\u0435\u043a\u0442\u0435 \u0438 \u0441\u043e\u0431\u0440\u0430\u043b boilerplate"},"content":{"rendered":"<div xmlns=\"http:\/\/www.w3.org\/1999\/xhtml\">\n<h2>\u041a\u0430\u043a \u044f \u043f\u0435\u0440\u0435\u0441\u0442\u0430\u043b \u043a\u043e\u043f\u0438\u043f\u0430\u0441\u0442\u0438\u0442\u044c \u043e\u0434\u043d\u043e \u0438 \u0442\u043e \u0436\u0435 \u0432 \u043a\u0430\u0436\u0434\u043e\u043c Django-\u043f\u0440\u043e\u0435\u043a\u0442\u0435 \u0438 \u0441\u043e\u0431\u0440\u0430\u043b boilerplate<\/h2>\n<p>\u041a\u0430\u0436\u0434\u044b\u0439 \u0440\u0430\u0437, \u043a\u043e\u0433\u0434\u0430 \u043d\u0430\u0447\u0438\u043d\u0430\u0435\u0448\u044c \u043d\u043e\u0432\u044b\u0439 SaaS-\u043f\u0440\u043e\u0435\u043a\u0442 \u043d\u0430 Django, \u043f\u0435\u0440\u0432\u044b\u0435 \u0434\u0432\u0435 \u043d\u0435\u0434\u0435\u043b\u0438 \u0443\u0445\u043e\u0434\u044f\u0442 \u043d\u0430 \u043e\u0434\u043d\u043e \u0438 \u0442\u043e \u0436\u0435. \u0421\u043d\u0430\u0447\u0430\u043b\u0430 \u2014 \u043a\u0430\u0441\u0442\u043e\u043c\u043d\u0430\u044f \u043c\u043e\u0434\u0435\u043b\u044c \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u0441 UUID \u0432\u043c\u0435\u0441\u0442\u043e integer PK, \u043f\u043e\u0442\u043e\u043c\u0443 \u0447\u0442\u043e \u043f\u043e\u0442\u043e\u043c \u043d\u0435 \u043f\u0435\u0440\u0435\u0435\u0434\u0435\u0448\u044c. \u041f\u043e\u0442\u043e\u043c JWT-\u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f, \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 SimpleJWT, \u043d\u0430\u043f\u0438\u0441\u0430\u043d\u0438\u0435\u00a0<code>RegisterView<\/code>,\u00a0<code>LoginView<\/code>,\u00a0<code>LogoutView<\/code>\u00a0\u2014 \u0432\u0441\u0451 \u044d\u0442\u043e \u0443\u0436\u0435 \u0431\u044b\u043b\u043e \u0432 \u043f\u0440\u043e\u0448\u043b\u043e\u043c \u043f\u0440\u043e\u0435\u043a\u0442\u0435, \u043d\u043e \u043b\u0435\u0436\u0438\u0442 \u0432 \u0434\u0440\u0443\u0433\u043e\u043c \u0440\u0435\u043f\u043e\u0437\u0438\u0442\u043e\u0440\u0438\u0438 \u0438 \u043f\u0440\u043e\u0441\u0442\u043e \u0442\u0430\u043a \u043d\u0435 \u0441\u043a\u043e\u043f\u0438\u0440\u0443\u0435\u0448\u044c. \u0414\u0430\u043b\u044c\u0448\u0435 Docker Compose: \u0441\u0435\u0440\u0432\u0438\u0441\u044b\u00a0<code>web<\/code>,\u00a0<code>db<\/code>,\u00a0<code>redis<\/code>,\u00a0<code>celery<\/code>,\u00a0<code>celery-beat<\/code>,\u00a0<code>flower<\/code>\u00a0\u2014 \u0448\u0435\u0441\u0442\u044c \u0448\u0442\u0443\u043a, \u043a\u043e\u0442\u043e\u0440\u044b\u0435 \u043d\u0430\u0434\u043e \u043f\u043e\u0434\u043d\u044f\u0442\u044c \u0438 \u0441\u0432\u044f\u0437\u0430\u0442\u044c \u043c\u0435\u0436\u0434\u0443 \u0441\u043e\u0431\u043e\u0439. \u041f\u043e\u0442\u043e\u043c \u0440\u0430\u0437\u0431\u0438\u0440\u0430\u0442\u044c\u0441\u044f \u0441 Celery, \u043a\u043e\u0442\u043e\u0440\u044b\u0439 \u0432 \u043d\u043e\u0432\u043e\u0439 \u0432\u0435\u0440\u0441\u0438\u0438 \u0438\u0437\u043c\u0435\u043d\u0438\u043b \u0441\u0438\u043d\u0442\u0430\u043a\u0441\u0438\u0441 \u043a\u043e\u043d\u0444\u0438\u0433\u0430. Stripe webhooks \u0441 \u0438\u0434\u0435\u043c\u043f\u043e\u0442\u0435\u043d\u0442\u043d\u043e\u0441\u0442\u044c\u044e \u2014 \u043e\u0442\u0434\u0435\u043b\u044c\u043d\u0430\u044f \u0438\u0441\u0442\u043e\u0440\u0438\u044f. \u041c\u0443\u043b\u044c\u0442\u0438\u0430\u0440\u0435\u043d\u0434\u043d\u043e\u0441\u0442\u044c, \u0440\u043e\u043b\u0438, permissions \u2014 \u0435\u0449\u0451 \u043d\u0435\u0434\u0435\u043b\u044f.<\/p>\n<p>\u0412 \u0438\u0442\u043e\u0433\u0435 \u043a \u043f\u0435\u0440\u0432\u043e\u0439 \u0440\u0430\u0431\u043e\u0447\u0435\u0439 \u0444\u0438\u0447\u0435 \u0434\u043e\u0431\u0438\u0440\u0430\u0435\u0448\u044c\u0441\u044f \u043a \u043a\u043e\u043d\u0446\u0443 \u0442\u0440\u0435\u0442\u044c\u0435\u0439 \u043d\u0435\u0434\u0435\u043b\u0438.<\/p>\n<p>\u0414\u043e\u0431\u0430\u0432\u044c \u0441\u044e\u0434\u0430 \u0435\u0449\u0451 \u043e\u0434\u0438\u043d \u043d\u044e\u0430\u043d\u0441: \u043a\u0430\u0436\u0434\u044b\u0439 \u0440\u0430\u0437 \u044d\u0442\u043e \u043f\u0440\u043e\u0435\u043a\u0442 \u0441 \u043d\u0435\u043c\u043d\u043e\u0433\u043e \u0434\u0440\u0443\u0433\u0438\u043c \u0441\u0442\u0435\u043a\u043e\u043c. \u0413\u0434\u0435-\u0442\u043e Kafka \u0432\u043c\u0435\u0441\u0442\u043e Redis, \u0433\u0434\u0435-\u0442\u043e allauth \u0441 \u0441\u0430\u043c\u043e\u0433\u043e \u043d\u0430\u0447\u0430\u043b\u0430, \u0433\u0434\u0435-\u0442\u043e \u0431\u0438\u043b\u043b\u0438\u043d\u0433 \u043d\u0430 \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f, \u0430 \u043d\u0435 \u043d\u0430 \u043a\u043e\u043c\u0430\u043d\u0434\u0443. \u041d\u043e \u044f\u0434\u0440\u043e \u043e\u0441\u0442\u0430\u0451\u0442\u0441\u044f \u043e\u0434\u043d\u0438\u043c: \u043a\u0430\u0436\u0434\u044b\u0439 \u0440\u0430\u0437 \u043f\u0435\u0440\u0435\u0434 \u0441\u0442\u0430\u0440\u0442\u043e\u043c \u0442\u044b \u0442\u0440\u0430\u0442\u0438\u0448\u044c \u043e\u0434\u0438\u043d\u0430\u043a\u043e\u0432\u043e \u043e\u0434\u043d\u0438 \u0438 \u0442\u0435 \u0436\u0435 \u0434\u0432\u0435 \u043d\u0435\u0434\u0435\u043b\u0438. \u0412\u043e\u0442 \u044d\u0442\u043e \u0438 \u0445\u043e\u0442\u0435\u043b\u043e\u0441\u044c \u0438\u0441\u043f\u0440\u0430\u0432\u0438\u0442\u044c.<\/p>\n<p>\u042f \u043f\u0440\u043e\u0448\u0451\u043b \u0447\u0435\u0440\u0435\u0437 \u044d\u0442\u043e \u043d\u0435\u0441\u043a\u043e\u043b\u044c\u043a\u043e \u0440\u0430\u0437 \u0438 \u0432 \u043a\u0430\u043a\u043e\u0439-\u0442\u043e \u043c\u043e\u043c\u0435\u043d\u0442 \u0440\u0435\u0448\u0438\u043b, \u0447\u0442\u043e \u0445\u0432\u0430\u0442\u0438\u0442. \u0421\u043e\u0431\u0440\u0430\u043b Django SaaS boilerplate \u043f\u043e\u0434 \u043d\u0430\u0437\u0432\u0430\u043d\u0438\u0435\u043c\u00a0<strong>Shipyard<\/strong>\u00a0\u2014 \u043d\u0435 \u043a\u0430\u043a \u043d\u0430\u0431\u043e\u0440 \u0441\u043d\u0438\u043f\u043f\u0435\u0442\u043e\u0432 \u0432 Notion, \u0430 \u043a\u0430\u043a \u043f\u043e\u043b\u043d\u043e\u0446\u0435\u043d\u043d\u044b\u0439, \u0433\u043e\u0442\u043e\u0432\u044b\u0439 \u043a production \u0440\u0435\u043f\u043e\u0437\u0438\u0442\u043e\u0440\u0438\u0439, \u043a\u043e\u0442\u043e\u0440\u044b\u0439 \u043c\u043e\u0436\u043d\u043e \u043a\u043b\u043e\u043d\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u0438 \u0441\u0440\u0430\u0437\u0443 \u043f\u0438\u0441\u0430\u0442\u044c \u043f\u0440\u043e\u0434\u0443\u043a\u0442\u043e\u0432\u0443\u044e \u043b\u043e\u0433\u0438\u043a\u0443.<\/p>\n<p>\u0412 \u044d\u0442\u043e\u0439 \u0441\u0442\u0430\u0442\u044c\u0435 \u0440\u0430\u0437\u0431\u0435\u0440\u0443, \u0447\u0442\u043e \u0442\u0430\u043c \u0432\u043d\u0443\u0442\u0440\u0438, \u043f\u043e\u0447\u0435\u043c\u0443 \u0432\u044b\u0431\u0440\u0430\u043d\u044b \u0438\u043c\u0435\u043d\u043d\u043e \u044d\u0442\u0438 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u044b \u0438 \u043a\u0430\u043a\u0438\u0435 \u043a\u043e\u043d\u043a\u0440\u0435\u0442\u043d\u044b\u0435 \u0442\u0435\u0445\u043d\u0438\u0447\u0435\u0441\u043a\u0438\u0435 \u0440\u0435\u0448\u0435\u043d\u0438\u044f \u043f\u043e\u043a\u0430\u0437\u0430\u043b\u0438\u0441\u044c \u043c\u043d\u0435 \u043d\u0430\u0438\u0431\u043e\u043b\u0435\u0435 \u0438\u043d\u0442\u0435\u0440\u0435\u0441\u043d\u044b\u043c\u0438.<\/p>\n<hr\/>\n<h3>\u0427\u0442\u043e \u0432\u0445\u043e\u0434\u0438\u0442 \u0432 Shipyard<\/h3>\n<p>\u041f\u0440\u0435\u0436\u0434\u0435 \u0447\u0435\u043c \u043f\u0435\u0440\u0435\u0445\u043e\u0434\u0438\u0442\u044c \u043a \u0434\u0435\u0442\u0430\u043b\u044f\u043c \u2014 \u0441\u0442\u0435\u043a \u0446\u0435\u043b\u0438\u043a\u043e\u043c.<\/p>\n<p><strong>Django 5 + Django REST Framework 3.15<\/strong>\u00a0\u2014 API-first \u0441 \u0441\u0430\u043c\u043e\u0433\u043e \u043d\u0430\u0447\u0430\u043b\u0430, \u043d\u0435 \u043a\u0430\u043a \u043d\u0430\u0434\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043d\u0430\u0434 HTML-\u0448\u0430\u0431\u043b\u043e\u043d\u0430\u043c\u0438. DRF \u0432\u044b\u0431\u0440\u0430\u043d \u043f\u043e\u0442\u043e\u043c\u0443, \u0447\u0442\u043e \u043e\u043d \u0434\u0435-\u0444\u0430\u043a\u0442\u043e \u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442, \u0445\u043e\u0440\u043e\u0448\u043e \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0438\u0440\u043e\u0432\u0430\u043d \u0438 \u0443 \u0431\u043e\u043b\u044c\u0448\u0438\u043d\u0441\u0442\u0432\u0430 \u0434\u0436\u0443\u043d\u0438\u043e\u0440\u043e\u0432 \u0432 \u043a\u043e\u043c\u0430\u043d\u0434\u0435 \u043d\u0435 \u0432\u044b\u0437\u043e\u0432\u0435\u0442 \u0432\u043e\u043f\u0440\u043e\u0441\u043e\u0432.<\/p>\n<p><strong>PostgreSQL 16 + Redis 7<\/strong>\u00a0\u2014 PostgreSQL \u043a\u0430\u043a \u043e\u0441\u043d\u043e\u0432\u043d\u0430\u044f \u0411\u0414: UUID-\u043f\u043e\u043b\u044f, JSONB \u0434\u043b\u044f \u043c\u0435\u0442\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u043f\u043b\u0430\u043d\u043e\u0432, pg_trgm \u0434\u043b\u044f \u0431\u0443\u0434\u0443\u0449\u0435\u0433\u043e \u043f\u043e\u0438\u0441\u043a\u0430. Redis \u2014 \u0431\u0440\u043e\u043a\u0435\u0440 \u0434\u043b\u044f Celery \u0438 \u043a\u044d\u0448. \u0414\u0432\u0430 \u043e\u0442\u0434\u0435\u043b\u044c\u043d\u044b\u0445 \u043a\u043e\u043d\u0442\u0435\u0439\u043d\u0435\u0440\u0430, \u043d\u0435 \u043e\u0434\u0438\u043d Redis \u043d\u0430 \u0432\u0441\u0451 \u043f\u043e\u0434\u0440\u044f\u0434.<\/p>\n<p><strong>Celery 5 + Celery Beat + Flower<\/strong>\u00a0\u2014 \u0430\u0441\u0438\u043d\u0445\u0440\u043e\u043d\u043d\u044b\u0435 \u0437\u0430\u0434\u0430\u0447\u0438 (\u043e\u0442\u043f\u0440\u0430\u0432\u043a\u0430 email, \u0441\u0438\u043d\u0445\u0440\u043e\u043d\u0438\u0437\u0430\u0446\u0438\u044f \u0441\u043e Stripe), \u043f\u0435\u0440\u0438\u043e\u0434\u0438\u0447\u0435\u0441\u043a\u0438\u0435 \u0437\u0430\u0434\u0430\u0447\u0438 \u0447\u0435\u0440\u0435\u0437 Beat, \u043c\u043e\u043d\u0438\u0442\u043e\u0440\u0438\u043d\u0433 \u0447\u0435\u0440\u0435\u0437 Flower \u043d\u0430 \u043f\u043e\u0440\u0442\u0443 5555. Beat \u0445\u0440\u0430\u043d\u0438\u0442 \u0440\u0430\u0441\u043f\u0438\u0441\u0430\u043d\u0438\u0435 \u0432 \u0411\u0414 \u0447\u0435\u0440\u0435\u0437\u00a0<code>django-celery-beat<\/code>, \u0430 \u043d\u0435 \u0432 \u0445\u0430\u0440\u0434\u043a\u043e\u0436\u0435\u043d\u043d\u043e\u043c\u00a0<code>CELERYBEAT_SCHEDULE<\/code>.<\/p>\n<p><strong>Docker Compose<\/strong>\u00a0\u2014 \u0434\u0432\u0430 \u0444\u0430\u0439\u043b\u0430:\u00a0<code>docker-compose.yml<\/code>\u00a0\u0434\u043b\u044f \u0440\u0430\u0437\u0440\u0430\u0431\u043e\u0442\u043a\u0438 \u0438\u00a0<a href=\"http:\/\/docker-compose.prod\" rel=\"noopener noreferrer nofollow\"><code>docker-compose.prod<\/code><\/a><code>.yml<\/code>\u00a0\u0434\u043b\u044f production. Dev-\u043a\u043e\u043d\u0444\u0438\u0433 \u043c\u043e\u043d\u0442\u0438\u0440\u0443\u0435\u0442 \u043b\u043e\u043a\u0430\u043b\u044c\u043d\u0443\u044e \u0434\u0438\u0440\u0435\u043a\u0442\u043e\u0440\u0438\u044e \u043a\u0430\u043a volume, prod \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442 \u043c\u043d\u043e\u0433\u043e\u044d\u0442\u0430\u043f\u043d\u0443\u044e \u0441\u0431\u043e\u0440\u043a\u0443 \u043e\u0431\u0440\u0430\u0437\u0430. \u041f\u043e\u0434\u0440\u043e\u0431\u043d\u0435\u0435 \u043e\u0431 entrypoint \u2014 \u043d\u0438\u0436\u0435.<\/p>\n<p><strong>GitHub Actions CI\/CD<\/strong>\u00a0\u2014 \u0442\u0440\u0438 workflow:\u00a0<code>ci.yml<\/code>\u00a0(lint + \u0442\u0435\u0441\u0442\u044b \u043d\u0430 \u043a\u0430\u0436\u0434\u044b\u0439 push\/PR),\u00a0<code>build.yml<\/code>\u00a0(\u0441\u0431\u043e\u0440\u043a\u0430 \u0438 \u043f\u0443\u0448 Docker-\u043e\u0431\u0440\u0430\u0437\u0430 \u043f\u0440\u0438 \u043c\u0435\u0440\u0436\u0435 \u0432\u00a0<code>main<\/code>),\u00a0<code>deploy.yml<\/code>\u00a0(\u0434\u0435\u043f\u043b\u043e\u0439 \u043f\u043e release-\u0442\u0435\u0433\u0443).<\/p>\n<p><strong>Stripe subscriptions + webhooks<\/strong>\u00a0\u2014 \u043c\u043e\u0434\u0435\u043b\u0438\u00a0<code>Plan<\/code>,\u00a0<code>Subscription<\/code>,\u00a0<code>Invoice<\/code>,\u00a0<code>WebhookEvent<\/code>. Checkout session, customer portal, \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0430 webhook-\u0441\u043e\u0431\u044b\u0442\u0438\u0439 \u0441 \u0438\u0434\u0435\u043c\u043f\u043e\u0442\u0435\u043d\u0442\u043d\u043e\u0441\u0442\u044c\u044e.<\/p>\n<p><strong>\u041c\u0443\u043b\u044c\u0442\u0438\u0430\u0440\u0435\u043d\u0434\u043d\u043e\u0441\u0442\u044c \u0441 RBAC<\/strong>\u00a0\u2014 \u0446\u0435\u043f\u043e\u0447\u043a\u0430\u00a0<code>User \u2192 TeamMembership \u2192 Team<\/code>. \u0422\u0440\u0438 \u0440\u043e\u043b\u0438:\u00a0<code>owner<\/code>,\u00a0<code>admin<\/code>,\u00a0<code>member<\/code>. DRF permissions \u043d\u0430 \u0443\u0440\u043e\u0432\u043d\u0435 view \u0438 \u043e\u0431\u044a\u0435\u043a\u0442\u0430.<\/p>\n<p><strong>Django Unfold<\/strong>\u00a0\u2014 \u0442\u0435\u043c\u0430 \u0434\u043b\u044f Django Admin. \u041d\u0435 \u043f\u0440\u0438\u043d\u0446\u0438\u043f\u0438\u0430\u043b\u044c\u043d\u043e, \u043d\u043e \u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u044b\u0439 admin \u0432 2025 \u0433\u043e\u0434\u0443 \u0432\u044b\u0433\u043b\u044f\u0434\u0438\u0442 dated, \u0430 Unfold \u0434\u0430\u0451\u0442 \u043d\u043e\u0440\u043c\u0430\u043b\u044c\u043d\u044b\u0439 UI \u0431\u0435\u0437 \u043d\u0430\u043f\u0438\u0441\u0430\u043d\u0438\u044f \u0441\u043e\u0431\u0441\u0442\u0432\u0435\u043d\u043d\u043e\u0433\u043e \u0448\u0430\u0431\u043b\u043e\u043d\u0430.<\/p>\n<p>\u0421\u0442\u0440\u0443\u043a\u0442\u0443\u0440\u0430 \u043f\u0440\u043e\u0435\u043a\u0442\u0430:<\/p>\n<pre><code>textshipyard\/\u251c\u2500\u2500 apps\/\u2502   \u251c\u2500\u2500 core\/          # \u0431\u0430\u0437\u043e\u0432\u044b\u0435 \u043c\u043e\u0434\u0435\u043b\u0438, health check, \u0443\u0442\u0438\u043b\u0438\u0442\u044b\u2502   \u251c\u2500\u2500 users\/         # \u043a\u0430\u0441\u0442\u043e\u043c\u043d\u044b\u0439 User, JWT auth, email verification\u2502   \u251c\u2500\u2500 teams\/         # Team, TeamMembership, \u0440\u043e\u043b\u0438, \u043f\u0440\u0438\u0433\u043b\u0430\u0448\u0435\u043d\u0438\u044f\u2502   \u251c\u2500\u2500 billing\/       # Plan, Subscription, Invoice, WebhookEvent\u2502   \u251c\u2500\u2500 notifications\/ # Celery tasks \u0434\u043b\u044f email, EmailLog\u2502   \u2514\u2500\u2500 api\/           # DRF router, versioning, throttling\u251c\u2500\u2500 config\/\u2502   \u251c\u2500\u2500 settings\/\u2502   \u2502   \u251c\u2500\u2500 base.py\u2502   \u2502   \u251c\u2500\u2500 development.py\u2502   \u2502   \u2514\u2500\u2500 production.py\u2502   \u2514\u2500\u2500 celery.py\u251c\u2500\u2500 docker\/\u2502   \u251c\u2500\u2500 dev\/\u2502   \u2514\u2500\u2500 prod\/\u2514\u2500\u2500 docker-compose.yml<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:87px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p><code>shipyard\/ \u251c\u2500\u2500 apps\/ \u2502   \u251c\u2500\u2500 core\/          # \u0431\u0430\u0437\u043e\u0432\u044b\u0435 \u043c\u043e\u0434\u0435\u043b\u0438, health check, \u0443\u0442\u0438\u043b\u0438\u0442\u044b \u2502   \u251c\u2500\u2500 users\/         # \u043a\u0430\u0441\u0442\u043e\u043c\u043d\u044b\u0439 User, JWT auth, email verification \u2502   \u251c\u2500\u2500 teams\/         # Team, TeamMembership, \u0440\u043e\u043b\u0438, \u043f\u0440\u0438\u0433\u043b\u0430\u0448\u0435\u043d\u0438\u044f \u2502   \u251c\u2500\u2500 billing\/       # Plan, Subscription, Invoice, WebhookEvent \u2502   \u251c\u2500\u2500 notifications\/ # Celery tasks \u0434\u043b\u044f email, EmailLog \u2502   \u2514\u2500\u2500 api\/           # DRF router, versioning, throttling \u251c\u2500\u2500 config\/ \u2502   \u251c\u2500\u2500 settings\/ \u2502   \u2502   \u251c\u2500\u2500 <\/code><a href=\"http:\/\/base.py\" rel=\"noopener noreferrer nofollow\"><code>base.py<\/code><\/a><code> \u2502   \u2502   \u251c\u2500\u2500 <\/code><a href=\"http:\/\/development.py\" rel=\"noopener noreferrer nofollow\"><code>development.py<\/code><\/a><code> \u2502   \u2502   \u2514\u2500\u2500 <\/code><a href=\"http:\/\/production.py\" rel=\"noopener noreferrer nofollow\"><code>production.py<\/code><\/a><code> \u2502   \u2514\u2500\u2500 <\/code><a href=\"http:\/\/celery.py\" rel=\"noopener noreferrer nofollow\"><code>celery.py<\/code><\/a><code> \u251c\u2500\u2500 docker\/ \u2502   \u251c\u2500\u2500 dev\/ \u2502   \u2514\u2500\u2500 prod\/ \u2514\u2500\u2500 docker-compose.yml<\/code><\/p>\n<hr\/>\n<h3>\u0410\u0440\u0445\u0438\u0442\u0435\u043a\u0442\u0443\u0440\u0430 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0439<\/h3>\n<h3>core<\/h3>\n<p>\u0417\u0434\u0435\u0441\u044c \u0436\u0438\u0432\u0443\u0442 \u0434\u0432\u0430 \u0430\u0431\u0441\u0442\u0440\u0430\u043a\u0442\u043d\u044b\u0445 \u043c\u0438\u043a\u0441\u0438\u043d\u0430, \u043a\u043e\u0442\u043e\u0440\u044b\u0435 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u044e\u0442\u0441\u044f \u043f\u043e\u0447\u0442\u0438 \u0432\u0435\u0437\u0434\u0435:<\/p>\n<pre><code>python# apps\/core\/models.pyimport uuidfrom django.db import modelsclass TimestampedModel(models.Model):    created_at = models.DateTimeField(auto_now_add=True)    updated_at = models.DateTimeField(auto_now=True)    class Meta:        abstract = Trueclass UUIDModel(models.Model):    id = models.UUIDField(        primary_key=True,        default=uuid.uuid4,        editable=False,    )    class Meta:        abstract = True<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p><code><em># apps\/core\/<\/em><\/code><a href=\"http:\/\/models.py\" rel=\"noopener noreferrer nofollow\"><code><em>models.py<\/em><\/code><\/a><code> <strong>import<\/strong> uuid <strong>from<\/strong> django.db <strong>import<\/strong> models   <strong>class<\/strong> TimestampedModel(models.Model):     created_at = models.DateTimeField(auto_now_add=True)     updated_at = models.DateTimeField(auto_now=True)      <strong>class<\/strong> Meta:         abstract = True   <strong>class<\/strong> UUIDModel(models.Model):     id = models.UUIDField(         primary_key=True,         default=uuid.uuid4,         editable=False,     )      <strong>class<\/strong> Meta:         abstract = True<\/code><\/p>\n<p>\u0411\u043e\u043b\u044c\u0448\u0438\u043d\u0441\u0442\u0432\u043e \u043c\u043e\u0434\u0435\u043b\u0435\u0439 \u043d\u0430\u0441\u043b\u0435\u0434\u0443\u0435\u0442 \u043e\u0431\u0430. UUID \u043a\u0430\u043a \u043f\u0435\u0440\u0432\u0438\u0447\u043d\u044b\u0439 \u043a\u043b\u044e\u0447 \u2014 \u0432\u044b\u0431\u043e\u0440 \u0441\u0434\u0435\u043b\u0430\u043d \u043e\u0434\u043d\u0430\u0436\u0434\u044b, \u0447\u0442\u043e\u0431\u044b \u043d\u0435 \u0432\u043e\u0437\u0432\u0440\u0430\u0449\u0430\u0442\u044c\u0441\u044f \u043a \u043d\u0435\u043c\u0443 \u043f\u043e\u0442\u043e\u043c. \u041f\u0440\u0435\u0434\u0441\u043a\u0430\u0437\u0443\u0435\u043c\u044b\u0435 auto-increment ID \u0432 URL \u2014 \u043d\u0435 \u043b\u0443\u0447\u0448\u0430\u044f \u0438\u0434\u0435\u044f \u0441 \u0442\u043e\u0447\u043a\u0438 \u0437\u0440\u0435\u043d\u0438\u044f \u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u043e\u0441\u0442\u0438, \u0438 \u043f\u0435\u0440\u0435\u0435\u0437\u0436\u0430\u0442\u044c \u0441 integer \u043d\u0430 UUID \u0432 \u0441\u0443\u0449\u0435\u0441\u0442\u0432\u0443\u044e\u0449\u0435\u0439 \u0411\u0414 \u0431\u043e\u043b\u0435\u0437\u043d\u0435\u043d\u043d\u043e.<\/p>\n<p>\u041a\u0440\u043e\u043c\u0435 \u043c\u043e\u0434\u0435\u043b\u0435\u0439, \u0437\u0434\u0435\u0441\u044c\u00a0<code>HealthCheckView<\/code>\u00a0\u0438\u00a0<code>ReadinessView<\/code>\u00a0\u043f\u043e \u043f\u0443\u0442\u044f\u043c\u00a0<code>\/health\/<\/code>\u00a0\u0438\u00a0<code>\/ready\/<\/code>\u00a0\u2014 \u043d\u0443\u0436\u043d\u044b \u0434\u043b\u044f Docker healthcheck \u0438 Kubernetes liveness\/readiness probe.<\/p>\n<h3>users<\/h3>\n<p>\u041a\u0430\u0441\u0442\u043e\u043c\u043d\u0430\u044f \u043c\u043e\u0434\u0435\u043b\u044c\u00a0<code>User<\/code>\u00a0\u0441 email \u043a\u0430\u043a \u043e\u0441\u043d\u043e\u0432\u043d\u044b\u043c \u0438\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440\u043e\u043c:<\/p>\n<pre><code>python# apps\/users\/models.pyclass User(UUIDModel, AbstractBaseUser, PermissionsMixin):    email = models.EmailField(unique=True)    full_name = models.CharField(max_length=255, blank=True)    avatar = models.ImageField(upload_to=\"avatars\/\", blank=True, null=True)    is_active = models.BooleanField(default=True)    is_staff = models.BooleanField(default=False)    is_email_verified = models.BooleanField(default=False)    timezone = models.CharField(max_length=50, default=\"UTC\")    stripe_customer_id = models.CharField(max_length=255, blank=True, null=True)    USERNAME_FIELD = \"email\"    REQUIRED_FIELDS = [\"full_name\"]    objects = CustomUserManager()    class Meta:        db_table = \"users_user\"        indexes = [            models.Index(fields=[\"email\"]),            models.Index(fields=[\"stripe_customer_id\"]),        ]<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p><code><em># apps\/users\/<\/em><\/code><a href=\"http:\/\/models.py\" rel=\"noopener noreferrer nofollow\"><code><em>models.py<\/em><\/code><\/a><code> <strong>class<\/strong> User(UUIDModel, AbstractBaseUser, PermissionsMixin):     email = models.EmailField(unique=True)     full_name = models.CharField(max_length=255, blank=True)     avatar = models.ImageField(upload_to=\"avatars\/\", blank=True, null=True)     is_active = models.BooleanField(default=True)     is_staff = models.BooleanField(default=False)     is_email_verified = models.BooleanField(default=False)     timezone = models.CharField(max_length=50, default=\"UTC\")     stripe_customer_id = models.CharField(max_length=255, blank=True, null=True)      USERNAME_FIELD = \"email\"     REQUIRED_FIELDS = [\"full_name\"]      objects = CustomUserManager()      <strong>class<\/strong> Meta:         db_table = \"users_user\"         indexes = [             models.Index(fields=[\"email\"]),             models.Index(fields=[\"stripe_customer_id\"]),         ]<\/code><\/p>\n<p><code>CustomUserManager<\/code>\u00a0\u043f\u0435\u0440\u0435\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u044f\u0435\u0442\u00a0<code>create_user<\/code>\u00a0\u0438\u00a0<code>create_superuser<\/code>\u00a0\u043f\u043e\u0434 email-based \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044e. \u041f\u043e\u043b\u0435\u00a0<code>stripe_customer_id<\/code>\u00a0\u043d\u0430 \u043c\u043e\u0434\u0435\u043b\u0438 \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u2014 \u044d\u0442\u043e \u0434\u043b\u044f \u0441\u043b\u0443\u0447\u0430\u044f, \u043a\u043e\u0433\u0434\u0430 billing \u043f\u0440\u0438\u0432\u044f\u0437\u0430\u043d \u043a \u0444\u0438\u0437\u0438\u0447\u0435\u0441\u043a\u043e\u043c\u0443 \u043b\u0438\u0446\u0443, \u0430 \u043d\u0435 \u043a \u043a\u043e\u043c\u0430\u043d\u0434\u0435. \u0412 SaaS \u0441 \u043a\u043e\u043c\u0430\u043d\u0434\u0430\u043c\u0438 \u043e\u0431\u044b\u0447\u043d\u043e customer \u0441\u043e\u0437\u0434\u0430\u0451\u0442\u0441\u044f \u043d\u0430\u00a0<code>Team<\/code>, \u043d\u043e \u0438\u043c\u0435\u0442\u044c \u043f\u043e\u043b\u0435 \u043d\u0430 \u043e\u0431\u043e\u0438\u0445 \u043d\u0435 \u0432\u0440\u0435\u0434\u043d\u043e.<\/p>\n<p>\u0414\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u043e \u0432 \u044d\u0442\u043e\u043c app-\u0435:\u00a0<code>EmailVerificationToken<\/code>\u00a0\u0438\u00a0<code>PasswordResetToken<\/code>\u00a0\u2014 \u043c\u043e\u0434\u0435\u043b\u0438 \u0441 \u043f\u043e\u043b\u044f\u043c\u0438\u00a0<code>token<\/code>,\u00a0<code>expires_at<\/code>,\u00a0<code>used_at<\/code>. \u041d\u0438\u043a\u0430\u043a\u043e\u0433\u043e \u043a\u044d\u0448\u0430 \u0438\u043b\u0438 \u043e\u0442\u0434\u0435\u043b\u044c\u043d\u043e\u0433\u043e \u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0430 \u2014 \u043f\u0440\u043e\u0441\u0442\u043e \u0441\u0442\u0440\u043e\u043a\u0438 \u0432 \u0411\u0414, \u0447\u0442\u043e \u0443\u0434\u043e\u0431\u043d\u043e \u043f\u0440\u0438 \u043e\u0442\u043b\u0430\u0434\u043a\u0435 \u0438 \u0430\u0443\u0434\u0438\u0442\u0435.<\/p>\n<h3>teams<\/h3>\n<p>\u0417\u0434\u0435\u0441\u044c \u0440\u0435\u0430\u043b\u0438\u0437\u043e\u0432\u0430\u043d\u0430 \u043c\u0443\u043b\u044c\u0442\u0438\u0430\u0440\u0435\u043d\u0434\u043d\u043e\u0441\u0442\u044c. \u0426\u0435\u043f\u043e\u0447\u043a\u0430 \u0432\u043b\u0430\u0434\u0435\u043d\u0438\u044f:<\/p>\n<pre><code>textUser \u2192 TeamMembership \u2192 Team<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p><code>User \u2192 TeamMembership \u2192 Team<\/code><\/p>\n<p><code>TeamMembership<\/code>\u00a0\u2014 \u044d\u0442\u043e through-\u0442\u0430\u0431\u043b\u0438\u0446\u0430 \u0434\u043b\u044f M2M \u043c\u0435\u0436\u0434\u0443\u00a0<code>User<\/code>\u00a0\u0438\u00a0<code>Team<\/code>, \u0434\u043e\u043f\u043e\u043b\u043d\u0435\u043d\u043d\u0430\u044f \u043f\u043e\u043b\u0435\u043c\u00a0<code>role<\/code>. \u041d\u0430\u00a0<code>Team<\/code>\u00a0\u0445\u0440\u0430\u043d\u044f\u0442\u0441\u044f \u043b\u0438\u043c\u0438\u0442\u044b (<code>max_members<\/code>,\u00a0<code>max_projects<\/code>), \u043a\u043e\u0442\u043e\u0440\u044b\u0435 \u0437\u0430\u043f\u043e\u043b\u043d\u044f\u044e\u0442\u0441\u044f \u043f\u0440\u0438 \u0430\u043a\u0442\u0438\u0432\u0430\u0446\u0438\u0438 \u043f\u043e\u0434\u043f\u0438\u0441\u043a\u0438 \u0438\u0437\u00a0<code>Plan<\/code>. \u042d\u0442\u043e \u0434\u0435\u043d\u043e\u0440\u043c\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u044f, \u043d\u043e \u043d\u0430\u043c\u0435\u0440\u0435\u043d\u043d\u0430\u044f: \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0430 \u043b\u0438\u043c\u0438\u0442\u0430 \u043f\u0440\u0438 \u043a\u0430\u0436\u0434\u043e\u043c \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0438 \u043d\u0435 \u0434\u043e\u043b\u0436\u043d\u0430 \u0442\u0440\u0435\u0431\u043e\u0432\u0430\u0442\u044c JOIN \u0447\u0435\u0440\u0435\u0437 billing.<\/p>\n<p><code>TeamInvitation<\/code>\u00a0\u2014 \u043c\u043e\u0434\u0435\u043b\u044c \u043f\u0440\u0438\u0433\u043b\u0430\u0448\u0435\u043d\u0438\u0439 \u0441\u043e \u0441\u0442\u0430\u0442\u0443\u0441\u0430\u043c\u0438\u00a0<code>pending \/ accepted \/ expired \/ revoked<\/code>. \u041f\u0440\u0438\u0433\u043b\u0430\u0448\u0435\u043d\u0438\u0435 \u2014 \u044d\u0442\u043e \u0437\u0430\u043f\u0438\u0441\u044c \u0432 \u0411\u0414 \u0441 \u0442\u043e\u043a\u0435\u043d\u043e\u043c \u0438 TTL, \u0430 \u043d\u0435 \u043f\u0440\u043e\u0441\u0442\u043e \u0441\u0441\u044b\u043b\u043a\u0430 \u0432 \u043f\u0438\u0441\u044c\u043c\u0435. \u042d\u0442\u043e \u043f\u043e\u0437\u0432\u043e\u043b\u044f\u0435\u0442 \u043e\u0442\u043e\u0437\u0432\u0430\u0442\u044c \u043f\u0440\u0438\u0433\u043b\u0430\u0448\u0435\u043d\u0438\u0435, \u043f\u043e\u0441\u043c\u043e\u0442\u0440\u0435\u0442\u044c \u0441\u043f\u0438\u0441\u043e\u043a \u043e\u0436\u0438\u0434\u0430\u044e\u0449\u0438\u0445 \u0438 \u043d\u0435 \u0431\u043e\u044f\u0442\u044c\u0441\u044f, \u0447\u0442\u043e \u043a\u0442\u043e-\u0442\u043e \u043f\u0435\u0440\u0435\u0445\u0432\u0430\u0442\u0438\u0442 \u0441\u0441\u044b\u043b\u043a\u0443 \u0434\u0432\u0443\u0445\u043b\u0435\u0442\u043d\u0435\u0439 \u0434\u0430\u0432\u043d\u043e\u0441\u0442\u0438.<\/p>\n<p>\u0421\u0438\u0433\u043d\u0430\u043b\u00a0<code>post_save<\/code>\u00a0\u043d\u0430\u00a0<code>Team<\/code>\u00a0\u043f\u0440\u043e\u0432\u0438\u0437\u0438\u043e\u043d\u0438\u0440\u0443\u0435\u0442 Stripe customer \u043f\u0440\u0438 \u0441\u043e\u0437\u0434\u0430\u043d\u0438\u0438 \u043a\u043e\u043c\u0430\u043d\u0434\u044b \u2014 \u043a\u043e\u043c\u0430\u043d\u0434\u0430 \u0441\u0440\u0430\u0437\u0443 \u0433\u043e\u0442\u043e\u0432\u0430 \u043a \u0431\u0438\u043b\u043b\u0438\u043d\u0433\u0443, \u0434\u0430\u0436\u0435 \u0435\u0441\u043b\u0438 \u043f\u043e\u0434\u043f\u0438\u0441\u043a\u0443 \u0430\u043a\u0442\u0438\u0432\u0438\u0440\u0443\u044e\u0442 \u043f\u043e\u0437\u0436\u0435.<\/p>\n<h3>billing<\/h3>\n<p>\u0427\u0435\u0442\u044b\u0440\u0435 \u043c\u043e\u0434\u0435\u043b\u0438:<\/p>\n<ul>\n<li>\n<p><code>Plan<\/code>\u00a0\u2014 \u0437\u0435\u0440\u043a\u0430\u043b\u0438\u0442 Stripe Product + Prices. \u0420\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u0443\u0435\u0442\u0441\u044f \u0447\u0435\u0440\u0435\u0437 Django Admin. \u0421\u043e\u0434\u0435\u0440\u0436\u0438\u0442\u00a0<code>stripe_product_id<\/code>,\u00a0<code>stripe_price_id_monthly<\/code>,\u00a0<code>stripe_price_id_yearly<\/code>, \u0430 \u0442\u0430\u043a\u0436\u0435 \u0434\u0435\u043d\u043e\u0440\u043c\u0430\u043b\u0438\u0437\u043e\u0432\u0430\u043d\u043d\u044b\u0435 \u043b\u0438\u043c\u0438\u0442\u044b:\u00a0<code>max_members<\/code>,\u00a0<code>max_projects<\/code>,\u00a0<code>has_api_access<\/code>.<\/p>\n<\/li>\n<li>\n<p><code>Subscription<\/code>\u00a0\u2014 \u0441\u0432\u044f\u0437\u044c\u00a0<code>Team<\/code>\u00a0(OneToOne) \u0441\u00a0<code>Plan<\/code>\u00a0\u0438 \u0432\u0441\u0435\u043c\u0438 \u043f\u043e\u043b\u044f\u043c\u0438 Stripe subscription: \u0441\u0442\u0430\u0442\u0443\u0441, \u043f\u0435\u0440\u0438\u043e\u0434,\u00a0<code>cancel_at_period_end<\/code>, trial.<\/p>\n<\/li>\n<li>\n<p><code>Invoice<\/code>\u00a0\u2014 \u0437\u0430\u043f\u0438\u0441\u044c \u043a\u0430\u0436\u0434\u043e\u0433\u043e \u0438\u043d\u0432\u043e\u0439\u0441\u0430 \u0441 \u0441\u0441\u044b\u043b\u043a\u0430\u043c\u0438 \u043d\u0430 PDF \u0438 hosted URL \u043e\u0442 Stripe.<\/p>\n<\/li>\n<li>\n<p><code>WebhookEvent<\/code>\u00a0\u2014 \u0436\u0443\u0440\u043d\u0430\u043b \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u0430\u043d\u043d\u044b\u0445 \u0441\u043e\u0431\u044b\u0442\u0438\u0439 \u0434\u043b\u044f \u0438\u0434\u0435\u043c\u043f\u043e\u0442\u0435\u043d\u0442\u043d\u043e\u0441\u0442\u0438.<\/p>\n<\/li>\n<\/ul>\n<h3>notifications<\/h3>\n<p><code>EmailLog<\/code>\u00a0\u2014 \u0430\u0443\u0434\u0438\u0442 \u043a\u0430\u0436\u0434\u043e\u0433\u043e \u0438\u0441\u0445\u043e\u0434\u044f\u0449\u0435\u0433\u043e \u043f\u0438\u0441\u044c\u043c\u0430: \u043a\u043e\u043c\u0443, \u0447\u0442\u043e, \u043a\u043e\u0433\u0434\u0430, \u0441\u0442\u0430\u0442\u0443\u0441. \u042d\u0442\u043e \u0438\u0437\u0431\u0430\u0432\u043b\u044f\u0435\u0442 \u043e\u0442 \u0432\u043e\u043f\u0440\u043e\u0441\u043e\u0432 &#171;\u0430 \u0434\u043e\u0448\u043b\u043e \u043b\u0438 \u043f\u0438\u0441\u044c\u043c\u043e&#187; \u0432 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u043a\u0435.<\/p>\n<p>\u0428\u0430\u0431\u043b\u043e\u043d\u044b \u043f\u0438\u0441\u0435\u043c \u0432\u00a0<code>apps\/notifications\/templates\/notifications\/<\/code>\u00a0\u2014 HTML \u0441 \u0431\u0430\u0437\u043e\u0432\u044b\u043c \u043b\u0435\u0439\u0430\u0443\u0442\u043e\u043c \u0438 \u043d\u0430\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u043d\u0438\u0435\u043c:\u00a0<code>welcome.html<\/code>,\u00a0<code>verify_email.html<\/code>,\u00a0<code>password_reset.html<\/code>,\u00a0<code>invitation.html<\/code>,\u00a0<code>billing_alert.html<\/code>,\u00a0<code>subscription_cancelled.html<\/code>.<\/p>\n<h3>api<\/h3>\n<p>\u0417\u0434\u0435\u0441\u044c \u043d\u0435\u0442 \u0431\u0438\u0437\u043d\u0435\u0441-\u043b\u043e\u0433\u0438\u043a\u0438 \u2014 \u0442\u043e\u043b\u044c\u043a\u043e \u0438\u043d\u0444\u0440\u0430\u0441\u0442\u0440\u0443\u043a\u0442\u0443\u0440\u0430 DRF:<\/p>\n<ul>\n<li>\n<p><a href=\"http:\/\/router.py\" rel=\"noopener noreferrer nofollow\"><code>router.py<\/code><\/a>\u00a0\u2014\u00a0<code>DefaultRouter<\/code>\u00a0\u0441\u043e \u0432\u0441\u0435\u043c\u0438 \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u044b\u043c\u0438 ViewSet-\u0430\u043c\u0438<\/p>\n<\/li>\n<li>\n<p><a href=\"http:\/\/throttling.py\" rel=\"noopener noreferrer nofollow\"><code>throttling.py<\/code><\/a>\u00a0\u2014\u00a0<code>AnonRateThrottle<\/code>,\u00a0<code>UserRateThrottle<\/code>,\u00a0<code>BurstThrottle<\/code><\/p>\n<\/li>\n<li>\n<p><a href=\"http:\/\/renderers.py\" rel=\"noopener noreferrer nofollow\"><code>renderers.py<\/code><\/a>\u00a0\u2014\u00a0<code>JSONRenderer<\/code>\u00a0\u0441 \u043a\u043e\u043d\u0432\u0435\u0440\u0442\u043e\u043c\u00a0<code>{\"data\": ..., \"meta\": ...}<\/code><\/p>\n<\/li>\n<li>\n<p><a href=\"http:\/\/exceptions.py\" rel=\"noopener noreferrer nofollow\"><code>exceptions.py<\/code><\/a>\u00a0\u2014 \u0435\u0434\u0438\u043d\u044b\u0439 \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a \u043e\u0448\u0438\u0431\u043e\u043a \u2192 \u0435\u0434\u0438\u043d\u044b\u0439 JSON-\u0444\u043e\u0440\u043c\u0430\u0442 \u043e\u0448\u0438\u0431\u043e\u043a<\/p>\n<\/li>\n<li>\n<p><a href=\"http:\/\/pagination.py\" rel=\"noopener noreferrer nofollow\"><code>pagination.py<\/code><\/a>\u00a0\u2014\u00a0<code>CursorPagination<\/code>\u00a0\u0434\u043b\u044f \u0441\u043f\u0438\u0441\u043a\u043e\u0432 (\u0431\u043e\u043b\u0435\u0435 \u0441\u0442\u0430\u0431\u0438\u043b\u044c\u043d\u0430\u044f, \u0447\u0435\u043c page-based, \u043f\u0440\u0438 \u0432\u044b\u0441\u043e\u043a\u043e\u0439 \u0447\u0430\u0441\u0442\u043e\u0442\u0435 \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u0439)<\/p>\n<\/li>\n<\/ul>\n<p>\u0412\u0441\u0435 endpoint-\u044b \u0441 \u043f\u0440\u0435\u0444\u0438\u043a\u0441\u043e\u043c\u00a0<code>\/api\/v1\/<\/code>. \u0412\u0435\u0440\u0441\u0438\u043e\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435 \u0447\u0435\u0440\u0435\u0437\u00a0<code>NamespaceVersioning<\/code>.<\/p>\n<hr\/>\n<h3>\u0418\u043d\u0442\u0435\u0440\u0435\u0441\u043d\u044b\u0435 \u0442\u0435\u0445\u043d\u0438\u0447\u0435\u0441\u043a\u0438\u0435 \u0440\u0435\u0448\u0435\u043d\u0438\u044f<\/h3>\n<h3>1. \u041c\u0443\u043b\u044c\u0442\u0438\u0430\u0440\u0435\u043d\u0434\u043d\u043e\u0441\u0442\u044c \u0447\u0435\u0440\u0435\u0437 RBAC<\/h3>\n<p>\u041f\u0440\u0438 \u043f\u0440\u043e\u0435\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0438 \u043c\u0443\u043b\u044c\u0442\u0438\u0430\u0440\u0435\u043d\u0434\u043d\u043e\u0441\u0442\u0438 \u0435\u0441\u0442\u044c \u043d\u0435\u0441\u043a\u043e\u043b\u044c\u043a\u043e \u043f\u043e\u0434\u0445\u043e\u0434\u043e\u0432. \u0421\u0430\u043c\u044b\u0439 \u043f\u0440\u043e\u0441\u0442\u043e\u0439 \u2014 \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c\u00a0<code>tenant_id<\/code>\u00a0\u043d\u0430 \u043a\u0430\u0436\u0434\u0443\u044e \u0442\u0430\u0431\u043b\u0438\u0446\u0443 \u0438 \u0444\u0438\u043b\u044c\u0442\u0440\u043e\u0432\u0430\u0442\u044c \u043f\u043e \u043d\u0435\u043c\u0443 \u0432 \u043a\u0430\u0436\u0434\u043e\u043c QuerySet. \u042d\u0442\u043e \u0440\u0430\u0431\u043e\u0442\u0430\u0435\u0442, \u043d\u043e \u0442\u0440\u0435\u0431\u0443\u0435\u0442 \u0434\u0438\u0441\u0446\u0438\u043f\u043b\u0438\u043d\u044b: \u043b\u0435\u0433\u043a\u043e \u0437\u0430\u0431\u044b\u0442\u044c \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u0444\u0438\u043b\u044c\u0442\u0440 \u0432 \u043e\u0434\u043d\u043e\u043c \u043c\u0435\u0441\u0442\u0435 \u0438 \u0441\u043b\u0443\u0447\u0430\u0439\u043d\u043e \u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c \u0434\u0430\u043d\u043d\u044b\u0435 \u0434\u0440\u0443\u0433\u043e\u0433\u043e \u0442\u0435\u043d\u0430\u043d\u0442\u0430. \u0412\u0442\u043e\u0440\u043e\u0439 \u043f\u043e\u0434\u0445\u043e\u0434 \u2014 \u043e\u0442\u0434\u0435\u043b\u044c\u043d\u044b\u0435 \u0441\u0445\u0435\u043c\u044b PostgreSQL \u0434\u043b\u044f \u043a\u0430\u0436\u0434\u043e\u0433\u043e \u0442\u0435\u043d\u0430\u043d\u0442\u0430 (<code>django-tenants<\/code>). \u042d\u0442\u043e \u0438\u0437\u043e\u043b\u044f\u0446\u0438\u044f \u043d\u0430 \u0443\u0440\u043e\u0432\u043d\u0435 \u0411\u0414, \u043d\u043e \u0443\u0441\u043b\u043e\u0436\u043d\u044f\u0435\u0442 \u0434\u0435\u043f\u043b\u043e\u0439, \u043c\u0438\u0433\u0440\u0430\u0446\u0438\u0438 \u0438 \u0430\u043d\u0430\u043b\u0438\u0442\u0438\u043a\u0443 \u0447\u0435\u0440\u0435\u0437 \u043d\u0435\u0441\u043a\u043e\u043b\u044c\u043a\u043e \u0441\u0445\u0435\u043c \u043e\u0434\u043d\u043e\u0432\u0440\u0435\u043c\u0435\u043d\u043d\u043e.<\/p>\n<p>\u0412 Shipyard \u0432\u044b\u0431\u0440\u0430\u043d \u0441\u0440\u0435\u0434\u043d\u0438\u0439 \u043f\u0443\u0442\u044c: \u043e\u0434\u0438\u043d \u043d\u0430\u0431\u043e\u0440 \u0442\u0430\u0431\u043b\u0438\u0446, \u043d\u043e \u0441 \u044f\u0432\u043d\u043e\u0439 \u0446\u0435\u043f\u043e\u0447\u043a\u043e\u0439 \u0432\u043b\u0430\u0434\u0435\u043d\u0438\u044f \u0447\u0435\u0440\u0435\u0437\u00a0<code>TeamMembership<\/code>. \u041a\u0430\u0436\u0434\u044b\u0439 \u0440\u0435\u0441\u0443\u0440\u0441 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0438\u043c\u0435\u0435\u0442 FK \u043d\u0430\u00a0<code>Team<\/code>. \u0412\u0441\u0435 ViewSet-\u044b \u0444\u0438\u043b\u044c\u0442\u0440\u0443\u044e\u0442 queryset \u043f\u043e\u00a0<code>team_id<\/code>\u00a0\u0438\u0437 URL, \u0438 \u044d\u0442\u043e\u0442 \u043f\u0430\u0442\u0442\u0435\u0440\u043d \u0434\u043e\u0441\u0442\u0430\u0442\u043e\u0447\u043d\u043e \u043c\u0435\u0445\u0430\u043d\u0438\u0447\u0435\u0441\u043a\u0438\u0439, \u0447\u0442\u043e\u0431\u044b \u0435\u0433\u043e \u0441\u043b\u043e\u0436\u043d\u043e \u0431\u044b\u043b\u043e \u043f\u0440\u043e\u043f\u0443\u0441\u0442\u0438\u0442\u044c \u043f\u0440\u0438 code review.<\/p>\n<p>\u0422\u0440\u0438 \u0440\u043e\u043b\u0438 \u0438 \u0441\u043e\u043e\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0443\u044e\u0449\u0438\u0435 DRF permissions:<\/p>\n<pre><code>python# apps\/teams\/permissions.pyfrom rest_framework.permissions import BasePermissionfrom apps.teams.models import TeamMembershipclass IsTeamMember(BasePermission):    def has_permission(self, request, view):        team_id = view.kwargs.get(\"team_id\")        return TeamMembership.objects.filter(            team_id=team_id,            user=request.user,        ).exists()class IsTeamAdmin(BasePermission):    def has_permission(self, request, view):        team_id = view.kwargs.get(\"team_id\")        return TeamMembership.objects.filter(            team_id=team_id,            user=request.user,            role__in=[TeamMembership.Role.ADMIN, TeamMembership.Role.OWNER],        ).exists()class IsTeamOwner(BasePermission):    def has_permission(self, request, view):        team_id = view.kwargs.get(\"team_id\")        return TeamMembership.objects.filter(            team_id=team_id,            user=request.user,            role=TeamMembership.Role.OWNER,        ).exists()<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p><code><em># apps\/teams\/<\/em><\/code><a href=\"http:\/\/permissions.py\" rel=\"noopener noreferrer nofollow\"><code><em>permissions.py<\/em><\/code><\/a><code> <strong>from<\/strong> rest_framework.permissions <strong>import<\/strong> BasePermission <strong>from<\/strong> apps.teams.models <strong>import<\/strong> TeamMembership   <strong>class<\/strong> IsTeamMember(BasePermission):     <strong>def<\/strong> has_permission(self, request, view):         team_id = view.kwargs.get(\"team_id\")         <strong>return<\/strong> TeamMembership.objects.filter(             team_id=team_id,             user=request.user,         ).exists()   <strong>class<\/strong> IsTeamAdmin(BasePermission):     <strong>def<\/strong> has_permission(self, request, view):         team_id = view.kwargs.get(\"team_id\")         <strong>return<\/strong> TeamMembership.objects.filter(             team_id=team_id,             user=request.user,             role__in=[TeamMembership.Role.ADMIN, TeamMembership.Role.OWNER],         ).exists()   <strong>class<\/strong> IsTeamOwner(BasePermission):     <strong>def<\/strong> has_permission(self, request, view):         team_id = view.kwargs.get(\"team_id\")         <strong>return<\/strong> TeamMembership.objects.filter(             team_id=team_id,             user=request.user,             role=TeamMembership.Role.OWNER,         ).exists()<\/code><\/p>\n<p>\u0412 ViewSet permissions \u0432\u044b\u0441\u0442\u0430\u0432\u043b\u044f\u044e\u0442\u0441\u044f \u043f\u043e \u043c\u0435\u0442\u043e\u0434\u0443:<\/p>\n<pre><code>python# apps\/teams\/views.pyclass TeamMembershipViewSet(viewsets.ModelViewSet):    def get_permissions(self):        if self.action in (\"update\", \"partial_update\", \"destroy\"):            return [IsAuthenticated(), IsTeamAdmin()]        return [IsAuthenticated(), IsTeamMember()]<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p><code><em># apps\/teams\/<\/em><\/code><a href=\"http:\/\/views.py\" rel=\"noopener noreferrer nofollow\"><code><em>views.py<\/em><\/code><\/a><code> <strong>class<\/strong> TeamMembershipViewSet(viewsets.ModelViewSet):     <strong>def<\/strong> get_permissions(self):         <strong>if<\/strong> self.action <strong>in<\/strong> (\"update\", \"partial_update\", \"destroy\"):             <strong>return<\/strong> [IsAuthenticated(), IsTeamAdmin()]         <strong>return<\/strong> [IsAuthenticated(), IsTeamMember()]<\/code><\/p>\n<p>\u041a\u0430\u0436\u0434\u044b\u0439 \u0437\u0430\u043f\u0440\u043e\u0441 \u0434\u0435\u043b\u0430\u0435\u0442 \u043e\u0434\u0438\u043d \u0434\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u044b\u0439 SELECT \u0432\u00a0<code>teams_membership<\/code>. \u041c\u043e\u0436\u043d\u043e \u043a\u044d\u0448\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u0447\u0435\u0440\u0435\u0437 Redis \u0441 \u043a\u043b\u044e\u0447\u043e\u043c\u00a0<code>membership:{user_id}:{team_id}<\/code>\u00a0\u0438 \u0438\u043d\u0432\u0430\u043b\u0438\u0434\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u043f\u0440\u0438 \u0438\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u0438 \u0440\u043e\u043b\u0438, \u043d\u043e \u043d\u0430 \u0441\u0442\u0430\u0440\u0442\u0435 \u044d\u0442\u043e preoptimization \u2014 QuerySet \u0441 \u0438\u043d\u0434\u0435\u043a\u0441\u043e\u043c \u043f\u043e\u00a0<code>(team_id, user_id)<\/code>\u00a0\u0441\u043f\u0440\u0430\u0432\u043b\u044f\u0435\u0442\u0441\u044f \u0431\u044b\u0441\u0442\u0440\u043e.<\/p>\n<p>\u0418\u043d\u0434\u0435\u043a\u0441\u00a0<code>(team, role)<\/code>\u00a0\u043d\u0430\u00a0<code>TeamMembership<\/code>\u00a0\u043f\u043e\u043c\u043e\u0433\u0430\u0435\u0442, \u043a\u043e\u0433\u0434\u0430 \u043d\u0430\u0447\u0438\u043d\u0430\u044e\u0442 \u043d\u0430\u043a\u0430\u043f\u043b\u0438\u0432\u0430\u0442\u044c\u0441\u044f \u0442\u044b\u0441\u044f\u0447\u0438 membership-\u0437\u0430\u043f\u0438\u0441\u0435\u0439:<\/p>\n<pre><code>pythonclass Meta:    db_table = \"teams_membership\"    unique_together = [(\"team\", \"user\")]    indexes = [models.Index(fields=[\"team\", \"role\"])]<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p><code><strong>class<\/strong> Meta:     db_table = \"teams_membership\"     unique_together = [(\"team\", \"user\")]     indexes = [models.Index(fields=[\"team\", \"role\"])]<\/code><\/p>\n<h3>2. Stripe webhooks \u0441 \u0438\u0434\u0435\u043c\u043f\u043e\u0442\u0435\u043d\u0442\u043d\u043e\u0441\u0442\u044c\u044e<\/h3>\n<p>Stripe \u043e\u0444\u0438\u0446\u0438\u0430\u043b\u044c\u043d\u043e \u0433\u0430\u0440\u0430\u043d\u0442\u0438\u0440\u0443\u0435\u0442 \u0434\u043e\u0441\u0442\u0430\u0432\u043a\u0443 \u00abat least once\u00bb \u2014 \u044d\u0442\u043e \u0437\u043d\u0430\u0447\u0438\u0442, \u0447\u0442\u043e \u0441\u043e\u0431\u044b\u0442\u0438\u0435 \u043c\u043e\u0436\u0435\u0442 \u043f\u0440\u0438\u0439\u0442\u0438 \u043d\u0435\u0441\u043a\u043e\u043b\u044c\u043a\u043e \u0440\u0430\u0437. \u041a\u0440\u043e\u043c\u0435 \u0442\u043e\u0433\u043e, \u0432 production \u043d\u0435\u0441\u043a\u043e\u043b\u044c\u043a\u043e \u0432\u043e\u0440\u043a\u0435\u0440\u043e\u0432 Celery \u0438\u043b\u0438 Gunicorn-\u043f\u0440\u043e\u0446\u0435\u0441\u0441\u043e\u0432 \u043c\u043e\u0433\u0443\u0442 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u043e\u0434\u043d\u043e \u0441\u043e\u0431\u044b\u0442\u0438\u0435 \u043f\u0430\u0440\u0430\u043b\u043b\u0435\u043b\u044c\u043d\u043e \u0447\u0435\u0440\u0435\u0437 \u0440\u0430\u0437\u043d\u044b\u0435 HTTP-\u0437\u0430\u043f\u0440\u043e\u0441\u044b, \u0435\u0441\u043b\u0438 \u0443 \u0432\u0430\u0441 \u0433\u043e\u0440\u0438\u0437\u043e\u043d\u0442\u0430\u043b\u044c\u043d\u044b\u0439 \u0434\u0435\u043f\u043b\u043e\u0439. \u0415\u0441\u043b\u0438 \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a \u043d\u0435 \u0438\u0434\u0435\u043c\u043f\u043e\u0442\u0435\u043d\u0442\u0435\u043d, \u043c\u043e\u0436\u043d\u043e \u0430\u043a\u0442\u0438\u0432\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u043e\u0434\u043d\u0443 \u043f\u043e\u0434\u043f\u0438\u0441\u043a\u0443 \u0434\u0432\u0430\u0436\u0434\u044b \u0438\u043b\u0438 \u0432\u044b\u0441\u0442\u0430\u0432\u0438\u0442\u044c \u0434\u0443\u0431\u043b\u0438\u0440\u0443\u044e\u0449\u0438\u0439 \u0438\u043d\u0432\u043e\u0439\u0441.<\/p>\n<p>\u041c\u043e\u0434\u0435\u043b\u044c\u00a0<code>WebhookEvent<\/code>:<\/p>\n<pre><code>python# apps\/billing\/models.pyclass WebhookEvent(TimestampedModel):    stripe_event_id = models.CharField(max_length=255, unique=True, db_index=True)    event_type = models.CharField(max_length=100)    payload = models.JSONField()    processed_at = models.DateTimeField(null=True, blank=True)    error = models.TextField(blank=True)    class Meta:        db_table = \"billing_webhook_event\"<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p><code><em># apps\/billing\/<\/em><\/code><a href=\"http:\/\/models.py\" rel=\"noopener noreferrer nofollow\"><code><em>models.py<\/em><\/code><\/a><code> <strong>class<\/strong> WebhookEvent(TimestampedModel):     stripe_event_id = models.CharField(max_length=255, unique=True, db_index=True)     event_type = models.CharField(max_length=100)     payload = models.JSONField()     processed_at = models.DateTimeField(null=True, blank=True)     error = models.TextField(blank=True)      <strong>class<\/strong> Meta:         db_table = \"billing_webhook_event\"<\/code><\/p>\n<p>\u041b\u043e\u0433\u0438\u043a\u0430 \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0438 \u0432\u00a0<a href=\"http:\/\/webhooks.py\" rel=\"noopener noreferrer nofollow\"><code>webhooks.py<\/code><\/a>:<\/p>\n<pre><code>python# apps\/billing\/webhooks.pyfrom django.utils import timezonefrom apps.billing.models import WebhookEventWEBHOOK_HANDLERS = {    \"checkout.session.completed\": handle_checkout_completed,    \"customer.subscription.updated\": handle_subscription_updated,    \"customer.subscription.deleted\": handle_subscription_deleted,    \"invoice.payment_succeeded\": handle_invoice_paid,    \"invoice.payment_failed\": handle_invoice_payment_failed,}def process_webhook(event) -&gt; None:    obj, created = WebhookEvent.objects.get_or_create(        stripe_event_id=event[\"id\"],        defaults={            \"event_type\": event[\"type\"],            \"payload\": event,        },    )    if not created:        # \u0421\u043e\u0431\u044b\u0442\u0438\u0435 \u0443\u0436\u0435 \u043e\u0431\u0440\u0430\u0431\u0430\u0442\u044b\u0432\u0430\u043b\u043e\u0441\u044c \u2014 \u043f\u0440\u043e\u043f\u0443\u0441\u043a\u0430\u0435\u043c        return    handler = WEBHOOK_HANDLERS.get(event[\"type\"])    if handler is None:        return    try:        handler(event)        obj.processed_at = timezone.now()        obj.save(update_fields=[\"processed_at\"])    except Exception as exc:        obj.error = str(exc)        obj.save(update_fields=[\"error\"])        raise<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p><code><em># apps\/billing\/<\/em><\/code><a href=\"http:\/\/webhooks.py\" rel=\"noopener noreferrer nofollow\"><code><em>webhooks.py<\/em><\/code><\/a><code> <strong>from<\/strong> django.utils <strong>import<\/strong> timezone <strong>from<\/strong> apps.billing.models <strong>import<\/strong> WebhookEvent  WEBHOOK_HANDLERS = {     \"checkout.session.completed\": handle_checkout_completed,     \"customer.subscription.updated\": handle_subscription_updated,     \"customer.subscription.deleted\": handle_subscription_deleted,     \"invoice.payment_succeeded\": handle_invoice_paid,     \"invoice.payment_failed\": handle_invoice_payment_failed, }  <strong>def<\/strong> process_webhook(event) -&gt; None:     obj, created = WebhookEvent.objects.get_or_create(         stripe_event_id=event[\"id\"],         defaults={             \"event_type\": event[\"type\"],             \"payload\": event,         },     )     <strong>if<\/strong> <strong>not<\/strong> created:         <em># \u0421\u043e\u0431\u044b\u0442\u0438\u0435 \u0443\u0436\u0435 \u043e\u0431\u0440\u0430\u0431\u0430\u0442\u044b\u0432\u0430\u043b\u043e\u0441\u044c \u2014 \u043f\u0440\u043e\u043f\u0443\u0441\u043a\u0430\u0435\u043c<\/em>         <strong>return<\/strong>      handler = WEBHOOK_HANDLERS.get(event[\"type\"])     <strong>if<\/strong> handler <strong>is<\/strong> None:         <strong>return<\/strong>      <strong>try<\/strong>:         handler(event)         obj.processed_at = <\/code><a href=\"http:\/\/timezone.now\" rel=\"noopener noreferrer nofollow\"><code>timezone.now<\/code><\/a><code>()         <\/code><a href=\"http:\/\/obj.save\" rel=\"noopener noreferrer nofollow\"><code>obj.save<\/code><\/a><code>(update_fields=[\"processed_at\"])     <strong>except<\/strong> Exception <strong>as<\/strong> exc:         obj.error = str(exc)         <\/code><a href=\"http:\/\/obj.save\" rel=\"noopener noreferrer nofollow\"><code>obj.save<\/code><\/a><code>(update_fields=[\"error\"])         <strong>raise<\/strong><\/code><\/p>\n<p><code>get_or_create<\/code>\u00a0\u043f\u043e\u00a0<code>stripe_event_id<\/code>\u00a0\u2014 \u0430\u0442\u043e\u043c\u0430\u0440\u043d\u0430\u044f \u043e\u043f\u0435\u0440\u0430\u0446\u0438\u044f \u0431\u043b\u0430\u0433\u043e\u0434\u0430\u0440\u044f\u00a0<code>unique=True<\/code>. \u0415\u0441\u043b\u0438 \u0434\u0432\u0430 \u043f\u0430\u0440\u0430\u043b\u043b\u0435\u043b\u044c\u043d\u044b\u0445 \u0432\u043e\u0440\u043a\u0435\u0440\u0430 \u043f\u043e\u043b\u0443\u0447\u0430\u0442 \u043e\u0434\u043d\u043e \u0441\u043e\u0431\u044b\u0442\u0438\u0435 \u043e\u0434\u043d\u043e\u0432\u0440\u0435\u043c\u0435\u043d\u043d\u043e, \u043e\u0434\u0438\u043d \u0438\u0437 \u043d\u0438\u0445 \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u00a0<code>IntegrityError<\/code>\u00a0\u0438 \u043d\u0435 \u043f\u0440\u043e\u0439\u0434\u0451\u0442 \u0434\u0430\u043b\u044c\u0448\u0435. \u042d\u0442\u043e \u043d\u0430\u0434\u0451\u0436\u043d\u0435\u0435, \u0447\u0435\u043c\u00a0<code>exists()<\/code>\u00a0+\u00a0<code>create()<\/code>, \u0433\u0434\u0435 \u043c\u0435\u0436\u0434\u0443 \u0434\u0432\u0443\u043c\u044f \u043e\u043f\u0435\u0440\u0430\u0446\u0438\u044f\u043c\u0438 \u0435\u0441\u0442\u044c \u0433\u043e\u043d\u043a\u0430.<\/p>\n<p>\u041e\u0442\u0434\u0435\u043b\u044c\u043d\u043e: view \u0434\u043b\u044f \u0432\u0435\u0431\u0445\u0443\u043a\u043e\u0432 \u043f\u0440\u043e\u0432\u0435\u0440\u044f\u0435\u0442 Stripe signature \u043f\u0435\u0440\u0435\u0434 \u0442\u0435\u043c, \u043a\u0430\u043a \u043f\u0430\u0440\u0441\u0438\u0442\u044c payload. \u042d\u0442\u043e \u0434\u0435\u043b\u0430\u0435\u0442\u0441\u044f \u0447\u0435\u0440\u0435\u0437\u00a0<code>stripe.Webhook.construct_event<\/code>\u00a0\u0441\u00a0<code>STRIPE_WEBHOOK_SECRET<\/code>\u00a0\u0438\u0437 \u043f\u0435\u0440\u0435\u043c\u0435\u043d\u043d\u043e\u0439 \u043e\u043a\u0440\u0443\u0436\u0435\u043d\u0438\u044f:<\/p>\n<pre><code>python# apps\/billing\/views.pyimport stripefrom django.conf import settingsfrom rest_framework.views import APIViewfrom rest_framework.response import Responsefrom rest_framework import statusfrom apps.billing.webhooks import process_webhookclass StripeWebhookView(APIView):    authentication_classes = []    permission_classes = []    def post(self, request):        payload = request.body        sig_header = request.META.get(\"HTTP_STRIPE_SIGNATURE\", \"\")        try:            event = stripe.Webhook.construct_event(                payload, sig_header, settings.STRIPE_WEBHOOK_SECRET            )        except (ValueError, stripe.error.SignatureVerificationError):            return Response(status=status.HTTP_400_BAD_REQUEST)        process_webhook(event)        return Response({\"status\": \"ok\"})<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p><code><em># apps\/billing\/<\/em><\/code><a href=\"http:\/\/views.py\" rel=\"noopener noreferrer nofollow\"><code><em>views.py<\/em><\/code><\/a><code> <strong>import<\/strong> stripe <strong>from<\/strong> django.conf <strong>import<\/strong> settings <strong>from<\/strong> rest_framework.views <strong>import<\/strong> APIView <strong>from<\/strong> rest_framework.response <strong>import<\/strong> Response <strong>from<\/strong> rest_framework <strong>import<\/strong> status <strong>from<\/strong> apps.billing.webhooks <strong>import<\/strong> process_webhook   <strong>class<\/strong> StripeWebhookView(APIView):     authentication_classes = []     permission_classes = []      <strong>def<\/strong> post(self, request):         payload = request.body         sig_header = request.META.get(\"HTTP_STRIPE_SIGNATURE\", \"\")         <strong>try<\/strong>:             event = stripe.Webhook.construct_event(                 payload, sig_header, settings.STRIPE_WEBHOOK_SECRET             )         <strong>except<\/strong> (ValueError, stripe.error.SignatureVerificationError):             <strong>return<\/strong> Response(status=status.HTTP_400_BAD_REQUEST)          process_webhook(event)         <strong>return<\/strong> Response({\"status\": \"ok\"})<\/code><\/p>\n<p>\u0411\u0435\u0437 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438 \u0441\u0438\u0433\u043d\u0430\u0442\u0443\u0440\u044b \u043b\u044e\u0431\u043e\u0439 \u043c\u043e\u0436\u0435\u0442 \u043e\u0442\u043f\u0440\u0430\u0432\u0438\u0442\u044c \u043f\u0440\u043e\u0438\u0437\u0432\u043e\u043b\u044c\u043d\u043e\u0435 \u0441\u043e\u0431\u044b\u0442\u0438\u0435 \u043d\u0430 endpoint \u0438, \u043d\u0430\u043f\u0440\u0438\u043c\u0435\u0440, \u0430\u043a\u0442\u0438\u0432\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u0447\u0443\u0436\u0443\u044e \u043f\u043e\u0434\u043f\u0438\u0441\u043a\u0443.<\/p>\n<h3>3. Docker entrypoint \u0441 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u043e\u0439 \u0411\u0414 \u0438 \u0443\u0441\u043b\u043e\u0432\u043d\u044b\u043c\u0438 \u043c\u0438\u0433\u0440\u0430\u0446\u0438\u044f\u043c\u0438<\/h3>\n<p>\u0422\u0438\u043f\u0438\u0447\u043d\u0430\u044f \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u0430 \u0441 Docker Compose:\u00a0<code>web<\/code>-\u043a\u043e\u043d\u0442\u0435\u0439\u043d\u0435\u0440 \u0441\u0442\u0430\u0440\u0442\u0443\u0435\u0442 \u0440\u0430\u043d\u044c\u0448\u0435, \u0447\u0435\u043c PostgreSQL \u0443\u0441\u043f\u0435\u0432\u0430\u0435\u0442 \u043f\u0440\u0438\u043d\u044f\u0442\u044c \u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u044f.\u00a0<code>depends_on: db<\/code>\u00a0\u0432 Compose-\u0444\u0430\u0439\u043b\u0435 \u0433\u0430\u0440\u0430\u043d\u0442\u0438\u0440\u0443\u0435\u0442 \u0442\u043e\u043b\u044c\u043a\u043e \u0442\u043e, \u0447\u0442\u043e \u043a\u043e\u043d\u0442\u0435\u0439\u043d\u0435\u0440\u00a0<code>db<\/code>\u00a0\u0437\u0430\u043f\u0443\u0449\u0435\u043d, \u043d\u043e \u043d\u0435 \u0442\u043e, \u0447\u0442\u043e PostgreSQL \u0432\u043d\u0443\u0442\u0440\u0438 \u043d\u0435\u0433\u043e \u0433\u043e\u0442\u043e\u0432 \u043f\u0440\u0438\u043d\u0438\u043c\u0430\u0442\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f. \u041d\u0430 \u043c\u0435\u0434\u043b\u0435\u043d\u043d\u043e\u0439 \u043c\u0430\u0448\u0438\u043d\u0435 \u0438\u043b\u0438 \u043f\u0440\u0438 \u043f\u0435\u0440\u0432\u043e\u043c \u0437\u0430\u043f\u0443\u0441\u043a\u0435 \u0441 \u0438\u043d\u0438\u0446\u0438\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u0435\u0439 \u0442\u043e\u043c\u0430 Django \u0443\u0441\u043f\u0435\u0432\u0430\u0435\u0442 \u0443\u043f\u0430\u0441\u0442\u044c \u0441\u00a0<code>connection refused<\/code>\u00a0\u0440\u0430\u043d\u044c\u0448\u0435, \u0447\u0435\u043c Postgres \u043f\u043e\u0434\u043d\u0438\u043c\u0435\u0442\u0441\u044f.<\/p>\n<pre><code>bash# docker\/dev\/entrypoint.sh#!\/bin\/shset -eecho \"Waiting for PostgreSQL...\"until pg_isready -h \"$POSTGRES_HOST\" -p \"${POSTGRES_PORT:-5432}\" -U \"$POSTGRES_USER\"; do  sleep 1doneecho \"PostgreSQL is ready.\"if [ \"${RUN_MIGRATIONS:-true}\" = \"true\" ]; then  echo \"Running migrations...\"  python manage.py migrate --noinputfiif [ \"${COLLECT_STATIC:-false}\" = \"true\" ]; then  python manage.py collectstatic --noinputfiexec \"$@\"<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p><code><em># docker\/dev\/<\/em><\/code><a href=\"http:\/\/entrypoint.sh\" rel=\"noopener noreferrer nofollow\"><code><em>entrypoint.sh<\/em><\/code><\/a><code> <em>#!\/bin\/sh<\/em> set -e  echo \"Waiting for PostgreSQL...\" <strong>until<\/strong> pg_isready -h \"$POSTGRES_HOST\" -p \"${POSTGRES_PORT:-5432}\" -U \"$POSTGRES_USER\"; <strong>do<\/strong>   sleep 1 <strong>done<\/strong> echo \"PostgreSQL is ready.\"  <strong>if<\/strong> [ \"${RUN_MIGRATIONS:-true}\" = \"true\" ]; <strong>then<\/strong>   echo \"Running migrations...\"   python <\/code><a href=\"http:\/\/manage.py\" rel=\"noopener noreferrer nofollow\"><code>manage.py<\/code><\/a><code> migrate --noinput <strong>fi<\/strong>  <strong>if<\/strong> [ \"${COLLECT_STATIC:-false}\" = \"true\" ]; <strong>then<\/strong>   python <\/code><a href=\"http:\/\/manage.py\" rel=\"noopener noreferrer nofollow\"><code>manage.py<\/code><\/a><code> collectstatic --noinput <strong>fi<\/strong>  exec \"$@\"<\/code><\/p>\n<p><code>pg_isready<\/code>\u00a0\u2014 \u0443\u0442\u0438\u043b\u0438\u0442\u0430 \u0438\u0437 \u043f\u0430\u043a\u0435\u0442\u0430\u00a0<code>postgresql-client<\/code>, \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043d\u0430\u044f \u0432 Dockerfile. \u041e\u043d\u0430 \u043f\u0438\u043d\u0433\u0443\u0435\u0442 \u0441\u0435\u0440\u0432\u0435\u0440 \u0431\u0435\u0437 \u043e\u0442\u043a\u0440\u044b\u0442\u0438\u044f full-connection, \u0447\u0442\u043e \u0431\u044b\u0441\u0442\u0440\u0435\u0435 \u0438 \u0434\u0435\u0448\u0435\u0432\u043b\u0435 \u0447\u0435\u043c\u00a0<code>python <\/code><a href=\"http:\/\/manage.py\" rel=\"noopener noreferrer nofollow\"><code>manage.py<\/code><\/a><code> check --database default<\/code>\u00a0\u0432 \u0446\u0438\u043a\u043b\u0435. \u0426\u0438\u043a\u043b \u0441\u00a0<code>sleep 1<\/code>\u00a0\u0432\u043f\u043e\u043b\u043d\u0435 \u0434\u043e\u0441\u0442\u0430\u0442\u043e\u0447\u0435\u043d \u0434\u043b\u044f dev \u2014 \u0432 production Compose \u043e\u0431\u044b\u0447\u043d\u043e \u0432\u0441\u0451 \u0440\u0430\u0432\u043d\u043e \u043d\u0435 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442\u0441\u044f.<\/p>\n<p>\u041f\u0435\u0440\u0435\u043c\u0435\u043d\u043d\u0430\u044f\u00a0<code>RUN_MIGRATIONS<\/code>\u00a0\u043f\u043e\u0437\u0432\u043e\u043b\u044f\u0435\u0442 \u043e\u0442\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u0430\u0432\u0442\u043e\u043c\u0438\u0433\u0440\u0430\u0446\u0438\u0438. \u042d\u0442\u043e \u043d\u0443\u0436\u043d\u043e, \u043d\u0430\u043f\u0440\u0438\u043c\u0435\u0440, \u043a\u043e\u0433\u0434\u0430 \u0440\u0430\u0437\u0432\u043e\u0440\u0430\u0447\u0438\u0432\u0430\u0435\u0448\u044c \u043d\u0435\u0441\u043a\u043e\u043b\u044c\u043a\u043e \u0440\u0435\u043f\u043b\u0438\u043a \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f: \u0442\u043e\u043b\u044c\u043a\u043e \u043e\u0434\u043d\u0430 \u0434\u043e\u043b\u0436\u043d\u0430 \u0437\u0430\u043f\u0443\u0441\u043a\u0430\u0442\u044c\u00a0<code>migrate<\/code>, \u043e\u0441\u0442\u0430\u043b\u044c\u043d\u044b\u0435 \u2014 \u0441\u0442\u0430\u0440\u0442\u043e\u0432\u0430\u0442\u044c \u0441\u0440\u0430\u0437\u0443. \u041f\u0430\u0440\u0430\u043b\u043b\u0435\u043b\u044c\u043d\u044b\u0439 \u0437\u0430\u043f\u0443\u0441\u043a\u00a0<code>migrate<\/code>\u00a0\u043d\u0435\u0441\u043a\u043e\u043b\u044c\u043a\u0438\u043c\u0438 \u043f\u0440\u043e\u0446\u0435\u0441\u0441\u0430\u043c\u0438 \u043e\u0431\u044b\u0447\u043d\u043e \u0431\u0435\u0437\u043e\u043f\u0430\u0441\u0435\u043d \u0431\u043b\u0430\u0433\u043e\u0434\u0430\u0440\u044f \u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u043a\u0430\u043c \u0432 Django, \u043d\u043e \u043b\u0443\u0447\u0448\u0435 \u043d\u0435 \u0438\u0441\u043f\u044b\u0442\u044b\u0432\u0430\u0442\u044c \u0441\u0443\u0434\u044c\u0431\u0443. \u0412 production \u044d\u0442\u043e \u0443\u043f\u0440\u0430\u0432\u043b\u044f\u0435\u0442\u0441\u044f \u0447\u0435\u0440\u0435\u0437 \u043e\u0442\u0434\u0435\u043b\u044c\u043d\u044b\u0439\u00a0<code>release command<\/code>\u00a0(\u043d\u0430\u043f\u0440\u0438\u043c\u0435\u0440, \u0432 Heroku\/Render) \u0438\u043b\u0438 init-\u043a\u043e\u043d\u0442\u0435\u0439\u043d\u0435\u0440.<\/p>\n<p>\u041f\u0435\u0440\u0435\u043c\u0435\u043d\u043d\u0430\u044f\u00a0<code>COLLECT_STATIC<\/code>\u00a0\u0430\u043d\u0430\u043b\u043e\u0433\u0438\u0447\u043d\u043e: \u0432 dev-\u043e\u043a\u0440\u0443\u0436\u0435\u043d\u0438\u0438 \u0441\u0442\u0430\u0442\u0438\u043a\u0430 \u0440\u0430\u0437\u0434\u0430\u0451\u0442\u0441\u044f \u043d\u0430\u043f\u0440\u044f\u043c\u0443\u044e \u0447\u0435\u0440\u0435\u0437\u00a0<code>runserver<\/code>, \u0432 prod \u2014 \u0447\u0435\u0440\u0435\u0437 Nginx \u043f\u043e\u0441\u043b\u0435\u00a0<code>collectstatic<\/code>\u00a0\u0432 CI.<\/p>\n<h3>4. \u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438, \u0440\u0430\u0437\u0434\u0435\u043b\u0451\u043d\u043d\u044b\u0435 \u043f\u043e \u043e\u043a\u0440\u0443\u0436\u0435\u043d\u0438\u044f\u043c<\/h3>\n<p>\u0421\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u044b\u0439 \u043f\u0430\u0442\u0442\u0435\u0440\u043d, \u043d\u043e \u0432\u0430\u0436\u043d\u043e \u0441\u0434\u0435\u043b\u0430\u0442\u044c \u0435\u0433\u043e \u043f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u043e \u0441 \u0441\u0430\u043c\u043e\u0433\u043e \u043d\u0430\u0447\u0430\u043b\u0430.<\/p>\n<pre><code>textconfig\/settings\/\u251c\u2500\u2500 base.py        # \u0432\u0441\u0451 \u043e\u0431\u0449\u0435\u0435\u251c\u2500\u2500 development.py # DEBUG=True, console email backend\u251c\u2500\u2500 production.py  # Gunicorn, S3, Redis cache, Sentry, strict security\u2514\u2500\u2500 testing.py     # \u0431\u044b\u0441\u0442\u0440\u044b\u0439 hasher, \u0442\u0435\u0441\u0442\u043e\u0432\u0430\u044f \u0411\u0414<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p><code>config\/settings\/ \u251c\u2500\u2500 <\/code><a href=\"http:\/\/base.py\" rel=\"noopener noreferrer nofollow\"><code>base.py<\/code><\/a><code>        # \u0432\u0441\u0451 \u043e\u0431\u0449\u0435\u0435 \u251c\u2500\u2500 <\/code><a href=\"http:\/\/development.py\" rel=\"noopener noreferrer nofollow\"><code>development.py<\/code><\/a><code> # DEBUG=True, console email backend \u251c\u2500\u2500 <\/code><a href=\"http:\/\/production.py\" rel=\"noopener noreferrer nofollow\"><code>production.py<\/code><\/a><code>  # Gunicorn, S3, Redis cache, Sentry, strict security \u2514\u2500\u2500 <\/code><a href=\"http:\/\/testing.py\" rel=\"noopener noreferrer nofollow\"><code>testing.py<\/code><\/a><code>     # \u0431\u044b\u0441\u0442\u0440\u044b\u0439 hasher, \u0442\u0435\u0441\u0442\u043e\u0432\u0430\u044f \u0411\u0414<\/code><\/p>\n<p><a href=\"http:\/\/base.py\" rel=\"noopener noreferrer nofollow\"><code>base.py<\/code><\/a>\u00a0\u0441\u043e\u0434\u0435\u0440\u0436\u0438\u0442\u00a0<code>INSTALLED_APPS<\/code>,\u00a0<code>MIDDLEWARE<\/code>, \u043a\u043e\u043d\u0444\u0438\u0433 DRF, Celery, \u0431\u0430\u0437\u043e\u0432\u044b\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0431\u0435\u0437 \u0441\u0435\u043a\u0440\u0435\u0442\u043e\u0432. \u0421\u0435\u043a\u0440\u0435\u0442\u044b \u2014 \u0442\u043e\u043b\u044c\u043a\u043e \u0447\u0435\u0440\u0435\u0437\u00a0<code>os.environ<\/code>, \u043d\u0438\u043a\u0430\u043a\u0438\u0445 fallback-\u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0439 \u0432 production-\u0444\u0430\u0439\u043b\u0435:<\/p>\n<pre><code>python# config\/settings\/base.pyimport osfrom pathlib import PathBASE_DIR = Path(__file__).resolve().parent.parent.parentSECRET_KEY = os.environ[\"SECRET_KEY\"]  # KeyError \u0435\u0441\u043b\u0438 \u043d\u0435 \u0437\u0430\u0434\u0430\u043d \u2014 \u044d\u0442\u043e \u043d\u0430\u043c\u0435\u0440\u0435\u043d\u043d\u043eDATABASES = {    \"default\": {        \"ENGINE\": \"django.db.backends.postgresql\",        \"NAME\": os.environ[\"POSTGRES_DB\"],        \"USER\": os.environ[\"POSTGRES_USER\"],        \"PASSWORD\": os.environ[\"POSTGRES_PASSWORD\"],        \"HOST\": os.environ.get(\"POSTGRES_HOST\", \"localhost\"),        \"PORT\": os.environ.get(\"POSTGRES_PORT\", \"5432\"),    }}<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p><code><em># config\/settings\/<\/em><\/code><a href=\"http:\/\/base.py\" rel=\"noopener noreferrer nofollow\"><code><em>base.py<\/em><\/code><\/a><code> <strong>import<\/strong> os <strong>from<\/strong> pathlib <strong>import<\/strong> Path  BASE_DIR = Path(__file__).resolve().parent.parent.parent  SECRET_KEY = os.environ[\"SECRET_KEY\"]  <em># KeyError \u0435\u0441\u043b\u0438 \u043d\u0435 \u0437\u0430\u0434\u0430\u043d \u2014 \u044d\u0442\u043e \u043d\u0430\u043c\u0435\u0440\u0435\u043d\u043d\u043e<\/em>  DATABASES = {     \"default\": {         \"ENGINE\": \"django.db.backends.postgresql\",         \"NAME\": os.environ[\"POSTGRES_DB\"],         \"USER\": os.environ[\"POSTGRES_USER\"],         \"PASSWORD\": os.environ[\"POSTGRES_PASSWORD\"],         \"HOST\": os.environ.get(\"POSTGRES_HOST\", \"<\/code><a href=\"http:\/\/localhost\" rel=\"noopener noreferrer nofollow\"><code>localhost<\/code><\/a><code>\"),         \"PORT\": os.environ.get(\"POSTGRES_PORT\", \"5432\"),     } }<\/code><\/p>\n<p><a href=\"http:\/\/development.py\" rel=\"noopener noreferrer nofollow\"><code>development.py<\/code><\/a>:<\/p>\n<pre><code>python# config\/settings\/development.pyfrom .base import *  # noqaDEBUG = TrueALLOWED_HOSTS = [\"*\"]EMAIL_BACKEND = \"django.core.mail.backends.console.EmailBackend\"CELERY_TASK_ALWAYS_EAGER = True  # \u0437\u0430\u0434\u0430\u0447\u0438 \u0432\u044b\u043f\u043e\u043b\u043d\u044f\u044e\u0442\u0441\u044f \u0441\u0438\u043d\u0445\u0440\u043e\u043d\u043d\u043e \u0432 devCELERY_TASK_EAGER_PROPAGATES = True<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p><code><em># config\/settings\/<\/em><\/code><a href=\"http:\/\/development.py\" rel=\"noopener noreferrer nofollow\"><code><em>development.py<\/em><\/code><\/a><code> <strong>from<\/strong> .base <strong>import<\/strong> <em>  # noqa  DEBUG = True ALLOWED_HOSTS = [\"<\/em>\"]  EMAIL_BACKEND = \"django.core.mail.backends.console.EmailBackend\"  CELERY_TASK_ALWAYS_EAGER = True  <em># \u0437\u0430\u0434\u0430\u0447\u0438 \u0432\u044b\u043f\u043e\u043b\u043d\u044f\u044e\u0442\u0441\u044f \u0441\u0438\u043d\u0445\u0440\u043e\u043d\u043d\u043e \u0432 dev<\/em> CELERY_TASK_EAGER_PROPAGATES = True<\/code><\/p>\n<p><code>CELERY_TASK_ALWAYS_EAGER = True<\/code>\u00a0\u0432 dev \u2014 \u043e\u0434\u043d\u043e \u0438\u0437 \u0441\u0430\u043c\u044b\u0445 \u0443\u0434\u043e\u0431\u043d\u044b\u0445 \u0440\u0435\u0448\u0435\u043d\u0438\u0439: \u0437\u0430\u0434\u0430\u0447\u0438 \u0432\u044b\u043f\u043e\u043b\u043d\u044f\u044e\u0442\u0441\u044f \u0441\u0438\u043d\u0445\u0440\u043e\u043d\u043d\u043e \u043f\u0440\u044f\u043c\u043e \u0432 \u043f\u0440\u043e\u0446\u0435\u0441\u0441\u0435 Django, \u043d\u0435 \u043d\u0443\u0436\u0435\u043d \u043e\u0442\u0434\u0435\u043b\u044c\u043d\u044b\u0439 \u0432\u043e\u0440\u043a\u0435\u0440 \u0434\u043b\u044f \u043b\u043e\u043a\u0430\u043b\u044c\u043d\u043e\u0439 \u0440\u0430\u0437\u0440\u0430\u0431\u043e\u0442\u043a\u0438. \u041d\u043e \u0432\u0430\u0436\u043d\u043e \u0432\u044b\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u044d\u0442\u043e \u0432\u00a0<a href=\"http:\/\/testing.py\" rel=\"noopener noreferrer nofollow\"><code>testing.py<\/code><\/a>, \u0438\u043d\u0430\u0447\u0435 \u0442\u0435\u0441\u0442\u044b \u043d\u0430 \u0430\u0441\u0438\u043d\u0445\u0440\u043e\u043d\u043d\u043e\u0435 \u043f\u043e\u0432\u0435\u0434\u0435\u043d\u0438\u0435 \u043d\u0435 \u0431\u0443\u0434\u0443\u0442 \u0440\u0430\u0431\u043e\u0442\u0430\u0442\u044c \u0447\u0435\u0441\u0442\u043d\u043e.<\/p>\n<p><a href=\"http:\/\/production.py\" rel=\"noopener noreferrer nofollow\"><code>production.py<\/code><\/a>\u00a0\u0434\u043e\u0431\u0430\u0432\u043b\u044f\u0435\u0442 Sentry, S3-\u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0435 \u0434\u043b\u044f \u043c\u0435\u0434\u0438\u0430\u0444\u0430\u0439\u043b\u043e\u0432, Redis \u043a\u0430\u043a \u043a\u044d\u0448-\u0431\u044d\u043a\u0435\u043d\u0434,\u00a0<code>SECURE_HSTS_SECONDS<\/code>,\u00a0<code>SESSION_COOKIE_SECURE<\/code>\u00a0\u0438 \u043f\u0440\u043e\u0447\u0438\u0435 security headers.<\/p>\n<hr\/>\n<h3>\u0427\u0442\u043e \u043d\u0435 \u0432\u043e\u0448\u043b\u043e \u0438 \u043f\u043e\u0447\u0435\u043c\u0443<\/h3>\n<p>\u041f\u043e\u043a\u0440\u044b\u0442\u0438\u0435 \u0442\u0435\u0441\u0442\u0430\u043c\u0438 \u2014 \u043e\u0442\u0434\u0435\u043b\u044c\u043d\u044b\u0439 \u0440\u0430\u0437\u0434\u0435\u043b. \u0412 \u043a\u0430\u0436\u0434\u043e\u043c app-\u0435 \u0435\u0441\u0442\u044c \u0434\u0438\u0440\u0435\u043a\u0442\u043e\u0440\u0438\u044f\u00a0<code>tests\/<\/code>\u00a0\u0441\u00a0<code>test_<\/code><a href=\"http:\/\/models.py\" rel=\"noopener noreferrer nofollow\"><code>models.py<\/code><\/a>,\u00a0<code>test_<\/code><a href=\"http:\/\/views.py\" rel=\"noopener noreferrer nofollow\"><code>views.py<\/code><\/a>\u00a0\u0438\u00a0<a href=\"http:\/\/factories.py\" rel=\"noopener noreferrer nofollow\"><code>factories.py<\/code><\/a>\u00a0(factory_boy). \u0412\u00a0<code>tests\/<\/code>\u00a0\u043d\u0430 \u0432\u0435\u0440\u0445\u043d\u0435\u043c \u0443\u0440\u043e\u0432\u043d\u0435 \u2014 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u043e\u043d\u043d\u044b\u0435 \u0442\u0435\u0441\u0442\u044b:\u00a0<code>test_auth_<\/code><a href=\"http:\/\/flow.py\" rel=\"noopener noreferrer nofollow\"><code>flow.py<\/code><\/a>,\u00a0<code>test_team_<\/code><a href=\"http:\/\/flow.py\" rel=\"noopener noreferrer nofollow\"><code>flow.py<\/code><\/a>,\u00a0<code>test_billing_<\/code><a href=\"http:\/\/flow.py\" rel=\"noopener noreferrer nofollow\"><code>flow.py<\/code><\/a>. \u0412\u00a0<code>tests\/fixtures\/stripe_webhook_events\/<\/code>\u00a0\u043b\u0435\u0436\u0430\u0442 JSON-\u0444\u0430\u0439\u043b\u044b \u0441 \u0440\u0435\u0430\u043b\u044c\u043d\u044b\u043c\u0438 Stripe-\u0441\u043e\u0431\u044b\u0442\u0438\u044f\u043c\u0438 \u0434\u043b\u044f \u0442\u0435\u0441\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f webhook-\u043e\u0431\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a\u043e\u0432 \u0431\u0435\u0437 \u043c\u043e\u043a\u043e\u0432. \u041a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f pytest \u0438 coverage \u2014 \u0432\u00a0<code>pyproject.toml<\/code>.<\/p>\n<p><strong>Frontend.<\/strong>\u00a0Shipyard \u2014 \u044d\u0442\u043e \u0447\u0438\u0441\u0442\u044b\u0439 API-backend. \u041d\u0438\u043a\u0430\u043a\u0438\u0445 \u0448\u0430\u0431\u043b\u043e\u043d\u043e\u0432, \u043d\u0438\u043a\u0430\u043a\u043e\u0433\u043e HTMX, \u043d\u0438\u043a\u0430\u043a\u043e\u0433\u043e React \u0432 \u0440\u0435\u043f\u043e\u0437\u0438\u0442\u043e\u0440\u0438\u0438. \u0412\u044b\u0431\u043e\u0440 \u0444\u0440\u043e\u043d\u0442\u0435\u043d\u0434-\u0441\u0442\u0435\u043a\u0430 \u2014 \u044d\u0442\u043e \u043e\u0442\u0434\u0435\u043b\u044c\u043d\u043e\u0435 \u0440\u0435\u0448\u0435\u043d\u0438\u0435, \u0438 \u0434\u0435\u043b\u0430\u0442\u044c \u0435\u0433\u043e \u0437\u0430 \u0440\u0430\u0437\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a\u0430 \u0431\u044b\u043b\u043e \u0431\u044b \u0441\u0430\u043c\u043e\u043d\u0430\u0434\u0435\u044f\u043d\u043d\u043e. Next.js, Nuxt, SvelteKit, \u043c\u043e\u0431\u0438\u043b\u044c\u043d\u043e\u0435 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u2014 \u0432\u0441\u0451 \u044d\u0442\u043e \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0430\u0435\u0442\u0441\u044f \u043a \u0442\u0435\u043c \u0436\u0435\u00a0<code>\/api\/v1\/<\/code>\u00a0endpoint-\u0430\u043c.<\/p>\n<p><strong>OAuth \/ \u0441\u043e\u0446\u0438\u0430\u043b\u044c\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f.<\/strong>\u00a0\u041a\u043e\u0434 \u0434\u043b\u044f \u044d\u0442\u043e\u0433\u043e \u0435\u0441\u0442\u044c (\u0444\u0430\u0439\u043b\u044b\u00a0<a href=\"http:\/\/adapters.py\" rel=\"noopener noreferrer nofollow\"><code>adapters.py<\/code><\/a>\u00a0\u0438\u00a0<a href=\"http:\/\/pipeline.py\" rel=\"noopener noreferrer nofollow\"><code>pipeline.py<\/code><\/a>\u00a0\u0432\u00a0<code>apps\/users\/<\/code>), \u043d\u043e \u043d\u0435 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0451\u043d \u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e. \u041f\u0440\u0438\u0447\u0438\u043d\u0430 \u043f\u0440\u043e\u0441\u0442\u0430\u044f: \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 OAuth-\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0439 \u0432 Google \u0438 GitHub \u0442\u0440\u0435\u0431\u0443\u0435\u0442 \u0440\u0435\u0430\u043b\u044c\u043d\u044b\u0445 callback URL-\u043e\u0432, \u0447\u0442\u043e \u043d\u0435\u0443\u0434\u043e\u0431\u043d\u043e \u043f\u0440\u0438 \u043f\u0435\u0440\u0432\u043e\u043c \u0437\u0430\u043f\u0443\u0441\u043a\u0435. \u0414\u043e\u0431\u0430\u0432\u0438\u0442\u044c\u00a0<code>django-allauth<\/code>\u00a0\u0432\u00a0<code>INSTALLED_APPS<\/code>\u00a0\u0438 \u043f\u0440\u043e\u043f\u0438\u0441\u0430\u0442\u044c credentials \u0432\u00a0<code>.env<\/code>\u00a0\u2014 20 \u043c\u0438\u043d\u0443\u0442 \u0440\u0430\u0431\u043e\u0442\u044b, \u043a\u043e\u0433\u0434\u0430 \u044d\u0442\u043e \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0442\u0435\u043b\u044c\u043d\u043e \u043d\u0443\u0436\u043d\u043e.<\/p>\n<p><strong>Kubernetes.<\/strong>\u00a0\u0415\u0441\u0442\u044c \u0434\u0432\u0430 Docker Compose \u0444\u0430\u0439\u043b\u0430: dev \u0438 prod. Kubernetes \u2014 \u044d\u0442\u043e \u0434\u0440\u0443\u0433\u043e\u0439 \u0443\u0440\u043e\u0432\u0435\u043d\u044c \u0441\u043b\u043e\u0436\u043d\u043e\u0441\u0442\u0438, \u043a\u043e\u0442\u043e\u0440\u044b\u0439 \u043e\u043f\u0440\u0430\u0432\u0434\u0430\u043d \u043f\u0440\u0438 \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0451\u043d\u043d\u043e\u043c \u043c\u0430\u0441\u0448\u0442\u0430\u0431\u0435. \u041d\u0430 \u0441\u0442\u0430\u0440\u0442\u0435 \u0431\u043e\u043b\u044c\u0448\u0438\u043d\u0441\u0442\u0432\u0443 SaaS \u0445\u0432\u0430\u0442\u0430\u0435\u0442 \u043e\u0434\u043d\u043e\u0433\u043e VPS \u0441 Docker Compose \u0438 \u043f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u043e \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u043d\u044b\u043c Nginx. \u0414\u043e\u0431\u0430\u0432\u043b\u044f\u0442\u044c Helm charts \u0432 boilerplate \u0437\u043d\u0430\u0447\u0438\u043b\u043e \u0431\u044b \u0443\u0441\u043b\u043e\u0436\u043d\u0438\u0442\u044c \u0442\u043e\u0447\u043a\u0443 \u0432\u0445\u043e\u0434\u0430 \u0431\u0435\u0437 \u0440\u0435\u0430\u043b\u044c\u043d\u043e\u0439 \u043f\u043e\u043b\u044c\u0437\u044b.<\/p>\n<p><strong>WebSocket \/ ASGI.<\/strong>\u00a0<a href=\"http:\/\/asgi.py\" rel=\"noopener noreferrer nofollow\"><code>asgi.py<\/code><\/a>\u00a0\u0435\u0441\u0442\u044c \u2014 \u043f\u0435\u0440\u0435\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043d\u0430 Daphne \u0438\u043b\u0438 Uvicorn \u043d\u0435\u0441\u043b\u043e\u0436\u043d\u043e, \u0435\u0441\u043b\u0438 \u043f\u043e\u043d\u0430\u0434\u043e\u0431\u0438\u0442\u0441\u044f real-time. \u041d\u043e Celery + polling \u0447\u0435\u0440\u0435\u0437 REST \u0440\u0435\u0448\u0430\u0435\u0442 \u0431\u043e\u043b\u044c\u0448\u0438\u043d\u0441\u0442\u0432\u043e \u0437\u0430\u0434\u0430\u0447 \u0431\u0435\u0437 \u0443\u0441\u043b\u043e\u0436\u043d\u0435\u043d\u0438\u044f \u0434\u0435\u043f\u043b\u043e\u044f.<\/p>\n<p><strong>\u041c\u043d\u043e\u0433\u043e\u044f\u0437\u044b\u0447\u043d\u043e\u0441\u0442\u044c.<\/strong>\u00a0\u0414\u0438\u0440\u0435\u043a\u0442\u043e\u0440\u0438\u044f\u00a0<code>locale\/en\/LC_MESSAGES\/<\/code>\u00a0\u0432 \u0440\u0435\u043f\u043e\u0437\u0438\u0442\u043e\u0440\u0438\u0438 \u0435\u0441\u0442\u044c,\u00a0<code>django.po<\/code>\u00a0\u043f\u0440\u0438\u0441\u0443\u0442\u0441\u0442\u0432\u0443\u0435\u0442 \u2014 Django i18n \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0451\u043d, \u043d\u043e \u043f\u0435\u0440\u0435\u0432\u043e\u0434\u044b \u043d\u0435 \u0437\u0430\u043f\u043e\u043b\u043d\u0435\u043d\u044b. \u042d\u0442\u043e \u0438\u043d\u0444\u0440\u0430\u0441\u0442\u0440\u0443\u043a\u0442\u0443\u0440\u0430 \u043f\u043e\u0434 \u0431\u0443\u0434\u0443\u0449\u0435\u0435.<\/p>\n<hr\/>\n<h3>\u0420\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442<\/h3>\n<p>\u0417\u0430\u043f\u0443\u0441\u043a \u0437\u0430\u043d\u0438\u043c\u0430\u0435\u0442 \u043f\u044f\u0442\u044c \u043a\u043e\u043c\u0430\u043d\u0434:<\/p>\n<pre><code>bashgit clone https:\/\/github.com\/EvgeniyMalykh\/Shipyard.gitcd Shipyardcp .env.example .envdocker compose up --builddocker compose exec web python manage.py createsuperuser<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p><code>git clone <\/code><a href=\"https:\/\/github.com\/EvgeniyMalykh\/Shipyard.git\" rel=\"noopener noreferrer nofollow\"><code>https:\/\/github.com\/EvgeniyMalykh\/Shipyard.git<\/code><\/a><code> cd Shipyard cp .env.example .env docker compose up --build docker compose exec web python <\/code><a href=\"http:\/\/manage.py\" rel=\"noopener noreferrer nofollow\"><code>manage.py<\/code><\/a><code> createsuperuser<\/code><\/p>\n<p>\u041f\u043e\u0441\u043b\u0435 \u044d\u0442\u043e\u0433\u043e \u0434\u043e\u0441\u0442\u0443\u043f\u043d\u044b:<\/p>\n<div>\n<div class=\"table\">\n<table>\n<tbody>\n<tr>\n<th>\n<p align=\"left\">\u0421\u0435\u0440\u0432\u0438\u0441<\/p>\n<\/th>\n<th>\n<p align=\"left\">URL<\/p>\n<\/th>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\">Django API<\/p>\n<\/td>\n<td>\n<p align=\"left\"><a href=\"http:\/\/localhost:8000\/api\/v1\/\" rel=\"noopener noreferrer nofollow\">http:\/\/localhost:8000\/api\/v1\/<\/a><\/p>\n<\/td>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\">Django Admin<\/p>\n<\/td>\n<td>\n<p align=\"left\"><a href=\"http:\/\/localhost:8000\/admin\/\" rel=\"noopener noreferrer nofollow\">http:\/\/localhost:8000\/admin\/<\/a><\/p>\n<\/td>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\">Flower<\/p>\n<\/td>\n<td>\n<p align=\"left\"><a href=\"http:\/\/localhost:5555\/\" rel=\"noopener noreferrer nofollow\">http:\/\/localhost:5555\/<\/a><\/p>\n<\/td>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\">Health check<\/p>\n<\/td>\n<td>\n<p align=\"left\"><a href=\"http:\/\/localhost:8000\/health\/\" rel=\"noopener noreferrer nofollow\">http:\/\/localhost:8000\/health\/<\/a><\/p>\n<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<\/div>\n<\/div>\n<p>\u042f \u0432\u044b\u043b\u043e\u0436\u0438\u043b Shipyard \u043d\u0430 GitHub:\u00a0<a href=\"https:\/\/github.com\/EvgeniyMalykh\/Shipyard\" rel=\"noopener noreferrer nofollow\">github.com\/EvgeniyMalykh\/Shipyard<\/a>. \u0422\u0430\u043c \u0436\u0435 \u043b\u0435\u0436\u0438\u0442\u00a0<a href=\"http:\/\/ARCHITECTURE.md\" rel=\"noopener noreferrer nofollow\"><code>ARCHITECTURE.md<\/code><\/a>\u00a0\u043d\u0430 2000+ \u0441\u0442\u0440\u043e\u043a \u2014 \u043f\u043e\u043b\u043d\u044b\u0439 \u0440\u0430\u0437\u0431\u043e\u0440 \u043a\u0430\u0436\u0434\u043e\u0433\u043e \u0444\u0430\u0439\u043b\u0430, \u0441\u0445\u0435\u043c\u044b \u0434\u0430\u043d\u043d\u044b\u0445, flow \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438 \u0438 Stripe-\u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438.<\/p>\n<p>\u0420\u0430\u043d\u043d\u0438\u0439 \u0434\u043e\u0441\u0442\u0443\u043f \u0441 \u043f\u043e\u0434\u0440\u043e\u0431\u043d\u043e\u0439 \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u0435\u0439 \u043f\u043e \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0435 \u0438 \u0434\u0435\u043f\u043b\u043e\u044e \u0434\u043e\u0441\u0442\u0443\u043f\u0435\u043d \u043d\u0430\u00a0<a href=\"https:\/\/malykh1.gumroad.com\/l\/fwuzc\" rel=\"noopener noreferrer nofollow\">Gumroad<\/a>. \u042d\u0442\u043e early access \u2014 \u0431\u0443\u0434\u0443 \u0440\u0430\u0434 \u0444\u0438\u0434\u0431\u044d\u043a\u0443 \u043f\u043e \u0430\u0440\u0445\u0438\u0442\u0435\u043a\u0442\u0443\u0440\u043d\u044b\u043c \u0440\u0435\u0448\u0435\u043d\u0438\u044f\u043c, \u043a\u043e\u0442\u043e\u0440\u044b\u0435 \u043a\u0430\u0436\u0443\u0442\u0441\u044f \u0441\u043f\u043e\u0440\u043d\u044b\u043c\u0438 \u0438\u043b\u0438 \u043a\u043e\u0442\u043e\u0440\u044b\u0435 \u0443 \u0432\u0430\u0441 \u0441\u0434\u0435\u043b\u0430\u043d\u044b \u0438\u043d\u0430\u0447\u0435.<\/p>\n<p>\u0415\u0441\u043b\u0438 \u0441\u0442\u0430\u0442\u044c\u044f \u0431\u044b\u043b\u0430 \u043f\u043e\u043b\u0435\u0437\u043d\u0430 \u0438 \u0432\u044b \u0434\u0435\u043b\u0430\u0435\u0442\u0435 \u0447\u0442\u043e-\u0442\u043e \u043f\u043e\u0445\u043e\u0436\u0435\u0435 \u0441\u0432\u043e\u0438\u043c\u0438 \u0440\u0443\u043a\u0430\u043c\u0438 \u2014 \u043d\u0430\u043f\u0438\u0448\u0438\u0442\u0435 \u0432 \u043a\u043e\u043c\u043c\u0435\u043d\u0442\u0430\u0440\u0438\u044f\u0445, \u043a\u0430\u043a\u0438\u0435 \u043a\u043e\u043c\u043f\u0440\u043e\u043c\u0438\u0441\u0441\u044b \u0432\u044b \u0441\u0434\u0435\u043b\u0430\u043b\u0438. \u0412 \u0447\u0430\u0441\u0442\u043d\u043e\u0441\u0442\u0438 \u0438\u043d\u0442\u0435\u0440\u0435\u0441\u043d\u043e, \u043a\u0430\u043a \u0434\u0440\u0443\u0433\u0438\u0435 \u0440\u0435\u0448\u0430\u044e\u0442 \u0432\u043e\u043f\u0440\u043e\u0441 \u0441 \u043c\u0443\u043b\u044c\u0442\u0438\u0430\u0440\u0435\u043d\u0434\u043d\u043e\u0441\u0442\u044c\u044e: \u0447\u0435\u0440\u0435\u0437 \u043e\u0442\u0434\u0435\u043b\u044c\u043d\u044b\u0435 \u0441\u0445\u0435\u043c\u044b PostgreSQL, \u0447\u0435\u0440\u0435\u0437\u00a0<code>tenant_id<\/code>\u00a0\u043d\u0430 \u043a\u0430\u0436\u0434\u043e\u0439 \u0442\u0430\u0431\u043b\u0438\u0446\u0435 \u0438\u043b\u0438 \u043a\u0430\u043a-\u0442\u043e \u0438\u043d\u0430\u0447\u0435. \u041d\u0443 \u0438 \u043f\u043e \u043b\u044e\u0431\u044b\u043c \u0434\u0440\u0443\u0433\u0438\u043c \u0430\u0440\u0445\u0438\u0442\u0435\u043a\u0442\u0443\u0440\u043d\u044b\u043c \u0440\u0435\u0448\u0435\u043d\u0438\u044f\u043c \u2014 \u0442\u043e\u0436\u0435 \u0438\u043d\u0442\u0435\u0440\u0435\u0441\u043d\u043e \u0443\u0441\u043b\u044b\u0448\u0430\u0442\u044c.<\/p>\n<\/div>\n<p>\u0441\u0441\u044b\u043b\u043a\u0430 \u043d\u0430 \u043e\u0440\u0438\u0433\u0438\u043d\u0430\u043b \u0441\u0442\u0430\u0442\u044c\u0438 <a href=\"https:\/\/habr.com\/ru\/articles\/1025002\/\">https:\/\/habr.com\/ru\/articles\/1025002\/<\/a><\/p>\n","protected":false},"excerpt":{"rendered":"<p>\u041a\u0430\u043a \u044f \u043f\u0435\u0440\u0435\u0441\u0442\u0430\u043b \u043a\u043e\u043f\u0438\u043f\u0430\u0441\u0442\u0438\u0442\u044c \u043e\u0434\u043d\u043e \u0438 \u0442\u043e \u0436\u0435 \u0432 \u043a\u0430\u0436\u0434\u043e\u043c Django-\u043f\u0440\u043e\u0435\u043a\u0442\u0435 \u0438 \u0441\u043e\u0431\u0440\u0430\u043b boilerplate\u041a\u0430\u0436\u0434\u044b\u0439 \u0440\u0430\u0437, \u043a\u043e\u0433\u0434\u0430 \u043d\u0430\u0447\u0438\u043d\u0430\u0435\u0448\u044c \u043d\u043e\u0432\u044b\u0439 SaaS-\u043f\u0440\u043e\u0435\u043a\u0442 \u043d\u0430 Django, \u043f\u0435\u0440\u0432\u044b\u0435 \u0434\u0432\u0435 \u043d\u0435\u0434\u0435\u043b\u0438 \u0443\u0445\u043e\u0434\u044f\u0442 \u043d\u0430 \u043e\u0434\u043d\u043e \u0438 \u0442\u043e \u0436\u0435. \u0421\u043d\u0430\u0447\u0430\u043b\u0430 \u2014 \u043a\u0430\u0441\u0442\u043e\u043c\u043d\u0430\u044f \u043c\u043e\u0434\u0435\u043b\u044c \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u0441 UUID \u0432\u043c\u0435\u0441\u0442\u043e integer PK, \u043f\u043e\u0442\u043e\u043c\u0443 \u0447\u0442\u043e \u043f\u043e\u0442\u043e\u043c \u043d\u0435 \u043f\u0435\u0440\u0435\u0435\u0434\u0435\u0448\u044c. \u041f\u043e\u0442\u043e\u043c JWT-\u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f, \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 SimpleJWT, \u043d\u0430\u043f\u0438\u0441\u0430\u043d\u0438\u0435\u00a0RegisterView,\u00a0LoginView,\u00a0LogoutView\u00a0\u2014 \u0432\u0441\u0451 \u044d\u0442\u043e \u0443\u0436\u0435 \u0431\u044b\u043b\u043e \u0432 \u043f\u0440\u043e\u0448\u043b\u043e\u043c \u043f\u0440\u043e\u0435\u043a\u0442\u0435, \u043d\u043e \u043b\u0435\u0436\u0438\u0442 \u0432 \u0434\u0440\u0443\u0433\u043e\u043c \u0440\u0435\u043f\u043e\u0437\u0438\u0442\u043e\u0440\u0438\u0438 \u0438 \u043f\u0440\u043e\u0441\u0442\u043e \u0442\u0430\u043a \u043d\u0435 \u0441\u043a\u043e\u043f\u0438\u0440\u0443\u0435\u0448\u044c. \u0414\u0430\u043b\u044c\u0448\u0435 Docker Compose: \u0441\u0435\u0440\u0432\u0438\u0441\u044b\u00a0web,\u00a0db,\u00a0redis,\u00a0celery,\u00a0celery-beat,\u00a0flower\u00a0\u2014 \u0448\u0435\u0441\u0442\u044c \u0448\u0442\u0443\u043a, \u043a\u043e\u0442\u043e\u0440\u044b\u0435 \u043d\u0430\u0434\u043e \u043f\u043e\u0434\u043d\u044f\u0442\u044c \u0438 \u0441\u0432\u044f\u0437\u0430\u0442\u044c \u043c\u0435\u0436\u0434\u0443 \u0441\u043e\u0431\u043e\u0439. \u041f\u043e\u0442\u043e\u043c \u0440\u0430\u0437\u0431\u0438\u0440\u0430\u0442\u044c\u0441\u044f \u0441 Celery, \u043a\u043e\u0442\u043e\u0440\u044b\u0439 \u0432 \u043d\u043e\u0432\u043e\u0439 \u0432\u0435\u0440\u0441\u0438\u0438 \u0438\u0437\u043c\u0435\u043d\u0438\u043b \u0441\u0438\u043d\u0442\u0430\u043a\u0441\u0438\u0441 \u043a\u043e\u043d\u0444\u0438\u0433\u0430. Stripe webhooks \u0441 \u0438\u0434\u0435\u043c\u043f\u043e\u0442\u0435\u043d\u0442\u043d\u043e\u0441\u0442\u044c\u044e \u2014 \u043e\u0442\u0434\u0435\u043b\u044c\u043d\u0430\u044f \u0438\u0441\u0442\u043e\u0440\u0438\u044f. \u041c\u0443\u043b\u044c\u0442\u0438\u0430\u0440\u0435\u043d\u0434\u043d\u043e\u0441\u0442\u044c, \u0440\u043e\u043b\u0438, permissions \u2014 \u0435\u0449\u0451 \u043d\u0435\u0434\u0435\u043b\u044f.\u0412 \u0438\u0442\u043e\u0433\u0435 \u043a \u043f\u0435\u0440\u0432\u043e\u0439 \u0440\u0430\u0431\u043e\u0447\u0435\u0439 \u0444\u0438\u0447\u0435 \u0434\u043e\u0431\u0438\u0440\u0430\u0435\u0448\u044c\u0441\u044f \u043a \u043a\u043e\u043d\u0446\u0443 \u0442\u0440\u0435\u0442\u044c\u0435\u0439 \u043d\u0435\u0434\u0435\u043b\u0438.\u0414\u043e\u0431\u0430\u0432\u044c \u0441\u044e\u0434\u0430 \u0435\u0449\u0451 \u043e\u0434\u0438\u043d \u043d\u044e\u0430\u043d\u0441: \u043a\u0430\u0436\u0434\u044b\u0439 \u0440\u0430\u0437 \u044d\u0442\u043e \u043f\u0440\u043e\u0435\u043a\u0442 \u0441 \u043d\u0435\u043c\u043d\u043e\u0433\u043e \u0434\u0440\u0443\u0433\u0438\u043c \u0441\u0442\u0435\u043a\u043e\u043c. \u0413\u0434\u0435-\u0442\u043e Kafka \u0432\u043c\u0435\u0441\u0442\u043e Redis, \u0433\u0434\u0435-\u0442\u043e allauth \u0441 \u0441\u0430\u043c\u043e\u0433\u043e \u043d\u0430\u0447\u0430\u043b\u0430, \u0433\u0434\u0435-\u0442\u043e \u0431\u0438\u043b\u043b\u0438\u043d\u0433 \u043d\u0430 \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f, \u0430 \u043d\u0435 \u043d\u0430 \u043a\u043e\u043c\u0430\u043d\u0434\u0443. \u041d\u043e \u044f\u0434\u0440\u043e \u043e\u0441\u0442\u0430\u0451\u0442\u0441\u044f \u043e\u0434\u043d\u0438\u043c: \u043a\u0430\u0436\u0434\u044b\u0439 \u0440\u0430\u0437 \u043f\u0435\u0440\u0435\u0434 \u0441\u0442\u0430\u0440\u0442\u043e\u043c \u0442\u044b \u0442\u0440\u0430\u0442\u0438\u0448\u044c \u043e\u0434\u0438\u043d\u0430\u043a\u043e\u0432\u043e \u043e\u0434\u043d\u0438 \u0438 \u0442\u0435 \u0436\u0435 \u0434\u0432\u0435 \u043d\u0435\u0434\u0435\u043b\u0438. \u0412\u043e\u0442 \u044d\u0442\u043e \u0438 \u0445\u043e\u0442\u0435\u043b\u043e\u0441\u044c \u0438\u0441\u043f\u0440\u0430\u0432\u0438\u0442\u044c.\u042f \u043f\u0440\u043e\u0448\u0451\u043b \u0447\u0435\u0440\u0435\u0437 \u044d\u0442\u043e \u043d\u0435\u0441\u043a\u043e\u043b\u044c\u043a\u043e \u0440\u0430\u0437 \u0438 \u0432 \u043a\u0430\u043a\u043e\u0439-\u0442\u043e \u043c\u043e\u043c\u0435\u043d\u0442 \u0440\u0435\u0448\u0438\u043b, \u0447\u0442\u043e \u0445\u0432\u0430\u0442\u0438\u0442. \u0421\u043e\u0431\u0440\u0430\u043b Django SaaS boilerplate \u043f\u043e\u0434 \u043d\u0430\u0437\u0432\u0430\u043d\u0438\u0435\u043c\u00a0Shipyard\u00a0\u2014 \u043d\u0435 \u043a\u0430\u043a \u043d\u0430\u0431\u043e\u0440 \u0441\u043d\u0438\u043f\u043f\u0435\u0442\u043e\u0432 \u0432 Notion, \u0430 \u043a\u0430\u043a \u043f\u043e\u043b\u043d\u043e\u0446\u0435\u043d\u043d\u044b\u0439, \u0433\u043e\u0442\u043e\u0432\u044b\u0439 \u043a production \u0440\u0435\u043f\u043e\u0437\u0438\u0442\u043e\u0440\u0438\u0439, \u043a\u043e\u0442\u043e\u0440\u044b\u0439 \u043c\u043e\u0436\u043d\u043e \u043a\u043b\u043e\u043d\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u0438 \u0441\u0440\u0430\u0437\u0443 \u043f\u0438\u0441\u0430\u0442\u044c \u043f\u0440\u043e\u0434\u0443\u043a\u0442\u043e\u0432\u0443\u044e \u043b\u043e\u0433\u0438\u043a\u0443.\u0412 \u044d\u0442\u043e\u0439 \u0441\u0442\u0430\u0442\u044c\u0435 \u0440\u0430\u0437\u0431\u0435\u0440\u0443, \u0447\u0442\u043e \u0442\u0430\u043c \u0432\u043d\u0443\u0442\u0440\u0438, \u043f\u043e\u0447\u0435\u043c\u0443 \u0432\u044b\u0431\u0440\u0430\u043d\u044b \u0438\u043c\u0435\u043d\u043d\u043e \u044d\u0442\u0438 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u044b \u0438 \u043a\u0430\u043a\u0438\u0435 \u043a\u043e\u043d\u043a\u0440\u0435\u0442\u043d\u044b\u0435 \u0442\u0435\u0445\u043d\u0438\u0447\u0435\u0441\u043a\u0438\u0435 \u0440\u0435\u0448\u0435\u043d\u0438\u044f \u043f\u043e\u043a\u0430\u0437\u0430\u043b\u0438\u0441\u044c \u043c\u043d\u0435 \u043d\u0430\u0438\u0431\u043e\u043b\u0435\u0435 \u0438\u043d\u0442\u0435\u0440\u0435\u0441\u043d\u044b\u043c\u0438.\u0427\u0442\u043e \u0432\u0445\u043e\u0434\u0438\u0442 \u0432 Shipyard\u041f\u0440\u0435\u0436\u0434\u0435 \u0447\u0435\u043c \u043f\u0435\u0440\u0435\u0445\u043e\u0434\u0438\u0442\u044c \u043a \u0434\u0435\u0442\u0430\u043b\u044f\u043c \u2014 \u0441\u0442\u0435\u043a \u0446\u0435\u043b\u0438\u043a\u043e\u043c.Django 5 + Django REST Framework 3.15\u00a0\u2014 API-first \u0441 \u0441\u0430\u043c\u043e\u0433\u043e \u043d\u0430\u0447\u0430\u043b\u0430, \u043d\u0435 \u043a\u0430\u043a \u043d\u0430\u0434\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043d\u0430\u0434 HTML-\u0448\u0430\u0431\u043b\u043e\u043d\u0430\u043c\u0438. DRF \u0432\u044b\u0431\u0440\u0430\u043d \u043f\u043e\u0442\u043e\u043c\u0443, \u0447\u0442\u043e \u043e\u043d \u0434\u0435-\u0444\u0430\u043a\u0442\u043e \u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442, \u0445\u043e\u0440\u043e\u0448\u043e \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0438\u0440\u043e\u0432\u0430\u043d \u0438 \u0443 \u0431\u043e\u043b\u044c\u0448\u0438\u043d\u0441\u0442\u0432\u0430 \u0434\u0436\u0443\u043d\u0438\u043e\u0440\u043e\u0432 \u0432 \u043a\u043e\u043c\u0430\u043d\u0434\u0435 \u043d\u0435 \u0432\u044b\u0437\u043e\u0432\u0435\u0442 \u0432\u043e\u043f\u0440\u043e\u0441\u043e\u0432.PostgreSQL 16 + Redis 7\u00a0\u2014 PostgreSQL \u043a\u0430\u043a \u043e\u0441\u043d\u043e\u0432\u043d\u0430\u044f \u0411\u0414: UUID-\u043f\u043e\u043b\u044f, JSONB \u0434\u043b\u044f \u043c\u0435\u0442\u0430\u0434\u0430\u043d\u043d\u044b\u0445 \u043f\u043b\u0430\u043d\u043e\u0432, pg_trgm \u0434\u043b\u044f \u0431\u0443\u0434\u0443\u0449\u0435\u0433\u043e \u043f\u043e\u0438\u0441\u043a\u0430. Redis \u2014 \u0431\u0440\u043e\u043a\u0435\u0440 \u0434\u043b\u044f Celery \u0438 \u043a\u044d\u0448. \u0414\u0432\u0430 \u043e\u0442\u0434\u0435\u043b\u044c\u043d\u044b\u0445 \u043a\u043e\u043d\u0442\u0435\u0439\u043d\u0435\u0440\u0430, \u043d\u0435 \u043e\u0434\u0438\u043d Redis \u043d\u0430 \u0432\u0441\u0451 \u043f\u043e\u0434\u0440\u044f\u0434.Celery 5 + Celery Beat + Flower\u00a0\u2014 \u0430\u0441\u0438\u043d\u0445\u0440\u043e\u043d\u043d\u044b\u0435 \u0437\u0430\u0434\u0430\u0447\u0438 (\u043e\u0442\u043f\u0440\u0430\u0432\u043a\u0430 email, \u0441\u0438\u043d\u0445\u0440\u043e\u043d\u0438\u0437\u0430\u0446\u0438\u044f \u0441\u043e Stripe), \u043f\u0435\u0440\u0438\u043e\u0434\u0438\u0447\u0435\u0441\u043a\u0438\u0435 \u0437\u0430\u0434\u0430\u0447\u0438 \u0447\u0435\u0440\u0435\u0437 Beat, \u043c\u043e\u043d\u0438\u0442\u043e\u0440\u0438\u043d\u0433 \u0447\u0435\u0440\u0435\u0437 Flower \u043d\u0430 \u043f\u043e\u0440\u0442\u0443 5555. Beat \u0445\u0440\u0430\u043d\u0438\u0442 \u0440\u0430\u0441\u043f\u0438\u0441\u0430\u043d\u0438\u0435 \u0432 \u0411\u0414 \u0447\u0435\u0440\u0435\u0437\u00a0django-celery-beat, \u0430 \u043d\u0435 \u0432 \u0445\u0430\u0440\u0434\u043a\u043e\u0436\u0435\u043d\u043d\u043e\u043c\u00a0CELERYBEAT_SCHEDULE.Docker Compose\u00a0\u2014 \u0434\u0432\u0430 \u0444\u0430\u0439\u043b\u0430:\u00a0docker-compose.yml\u00a0\u0434\u043b\u044f \u0440\u0430\u0437\u0440\u0430\u0431\u043e\u0442\u043a\u0438 \u0438\u00a0docker-compose.prod.yml\u00a0\u0434\u043b\u044f production. Dev-\u043a\u043e\u043d\u0444\u0438\u0433 \u043c\u043e\u043d\u0442\u0438\u0440\u0443\u0435\u0442 \u043b\u043e\u043a\u0430\u043b\u044c\u043d\u0443\u044e \u0434\u0438\u0440\u0435\u043a\u0442\u043e\u0440\u0438\u044e \u043a\u0430\u043a volume, prod \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442 \u043c\u043d\u043e\u0433\u043e\u044d\u0442\u0430\u043f\u043d\u0443\u044e \u0441\u0431\u043e\u0440\u043a\u0443 \u043e\u0431\u0440\u0430\u0437\u0430. \u041f\u043e\u0434\u0440\u043e\u0431\u043d\u0435\u0435 \u043e\u0431 entrypoint \u2014 \u043d\u0438\u0436\u0435.GitHub Actions CI\/CD\u00a0\u2014 \u0442\u0440\u0438 workflow:\u00a0ci.yml\u00a0(lint + \u0442\u0435\u0441\u0442\u044b \u043d\u0430 \u043a\u0430\u0436\u0434\u044b\u0439 push\/PR),\u00a0build.yml\u00a0(\u0441\u0431\u043e\u0440\u043a\u0430 \u0438 \u043f\u0443\u0448 Docker-\u043e\u0431\u0440\u0430\u0437\u0430 \u043f\u0440\u0438 \u043c\u0435\u0440\u0436\u0435 \u0432\u00a0main),\u00a0deploy.yml\u00a0(\u0434\u0435\u043f\u043b\u043e\u0439 \u043f\u043e release-\u0442\u0435\u0433\u0443).Stripe subscriptions + webhooks\u00a0\u2014 \u043c\u043e\u0434\u0435\u043b\u0438\u00a0Plan,\u00a0Subscription,\u00a0Invoice,\u00a0WebhookEvent. Checkout session, customer portal, \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0430 webhook-\u0441\u043e\u0431\u044b\u0442\u0438\u0439 \u0441 \u0438\u0434\u0435\u043c\u043f\u043e\u0442\u0435\u043d\u0442\u043d\u043e\u0441\u0442\u044c\u044e.\u041c\u0443\u043b\u044c\u0442\u0438\u0430\u0440\u0435\u043d\u0434\u043d\u043e\u0441\u0442\u044c \u0441 RBAC\u00a0\u2014 \u0446\u0435\u043f\u043e\u0447\u043a\u0430\u00a0User \u2192 TeamMembership \u2192 Team. \u0422\u0440\u0438 \u0440\u043e\u043b\u0438:\u00a0owner,\u00a0admin,\u00a0member. DRF permissions \u043d\u0430 \u0443\u0440\u043e\u0432\u043d\u0435 view \u0438 \u043e\u0431\u044a\u0435\u043a\u0442\u0430.Django Unfold\u00a0\u2014 \u0442\u0435\u043c\u0430 \u0434\u043b\u044f Django Admin. \u041d\u0435 \u043f\u0440\u0438\u043d\u0446\u0438\u043f\u0438\u0430\u043b\u044c\u043d\u043e, \u043d\u043e \u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u044b\u0439 admin \u0432 2025 \u0433\u043e\u0434\u0443 \u0432\u044b\u0433\u043b\u044f\u0434\u0438\u0442 dated, \u0430 Unfold \u0434\u0430\u0451\u0442 \u043d\u043e\u0440\u043c\u0430\u043b\u044c\u043d\u044b\u0439 UI \u0431\u0435\u0437 \u043d\u0430\u043f\u0438\u0441\u0430\u043d\u0438\u044f \u0441\u043e\u0431\u0441\u0442\u0432\u0435\u043d\u043d\u043e\u0433\u043e \u0448\u0430\u0431\u043b\u043e\u043d\u0430.\u0421\u0442\u0440\u0443\u043a\u0442\u0443\u0440\u0430 \u043f\u0440\u043e\u0435\u043a\u0442\u0430:textshipyard\/\u251c\u2500\u2500 apps\/\u2502   \u251c\u2500\u2500 core\/          # \u0431\u0430\u0437\u043e\u0432\u044b\u0435 \u043c\u043e\u0434\u0435\u043b\u0438, health check, \u0443\u0442\u0438\u043b\u0438\u0442\u044b\u2502   \u251c\u2500\u2500 users\/         # \u043a\u0430\u0441\u0442\u043e\u043c\u043d\u044b\u0439 User, JWT auth, email verification\u2502   \u251c\u2500\u2500 teams\/         # Team, TeamMembership, \u0440\u043e\u043b\u0438, \u043f\u0440\u0438\u0433\u043b\u0430\u0448\u0435\u043d\u0438\u044f\u2502   \u251c\u2500\u2500 billing\/       # Plan, Subscription, Invoice, WebhookEvent\u2502   \u251c\u2500\u2500 notifications\/ # Celery tasks \u0434\u043b\u044f email, EmailLog\u2502   \u2514\u2500\u2500 api\/           # DRF router, versioning, throttling\u251c\u2500\u2500 config\/\u2502   \u251c\u2500\u2500 settings\/\u2502   \u2502   \u251c\u2500\u2500 base.py\u2502   \u2502   \u251c\u2500\u2500 development.py\u2502   \u2502   \u2514\u2500\u2500 production.py\u2502   \u2514\u2500\u2500 celery.py\u251c\u2500\u2500 docker\/\u2502   \u251c\u2500\u2500 dev\/\u2502   \u2514\u2500\u2500 prod\/\u2514\u2500\u2500 docker-compose.ymlshipyard\/ \u251c\u2500\u2500 apps\/ \u2502   \u251c\u2500\u2500 core\/          # \u0431\u0430\u0437\u043e\u0432\u044b\u0435 \u043c\u043e\u0434\u0435\u043b\u0438, health check, \u0443\u0442\u0438\u043b\u0438\u0442\u044b \u2502   \u251c\u2500\u2500 users\/         # \u043a\u0430\u0441\u0442\u043e\u043c\u043d\u044b\u0439 User, JWT auth, email verification \u2502   \u251c\u2500\u2500 teams\/         # Team, TeamMembership, \u0440\u043e\u043b\u0438, \u043f\u0440\u0438\u0433\u043b\u0430\u0448\u0435\u043d\u0438\u044f \u2502   \u251c\u2500\u2500 billing\/       # Plan, Subscription, Invoice, WebhookEvent \u2502   \u251c\u2500\u2500 notifications\/ # Celery tasks \u0434\u043b\u044f email, EmailLog \u2502   \u2514\u2500\u2500 api\/           # DRF router, versioning, throttling \u251c\u2500\u2500 config\/ \u2502   \u251c\u2500\u2500 settings\/ \u2502   \u2502   \u251c\u2500\u2500 base.py \u2502   \u2502   \u251c\u2500\u2500 development.py \u2502   \u2502   \u2514\u2500\u2500 production.py \u2502   \u2514\u2500\u2500 celery.py \u251c\u2500\u2500 docker\/ \u2502   \u251c\u2500\u2500 dev\/ \u2502   \u2514\u2500\u2500 prod\/ \u2514\u2500\u2500 docker-compose.yml\u0410\u0440\u0445\u0438\u0442\u0435\u043a\u0442\u0443\u0440\u0430 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0439core\u0417\u0434\u0435\u0441\u044c \u0436\u0438\u0432\u0443\u0442 \u0434\u0432\u0430 \u0430\u0431\u0441\u0442\u0440\u0430\u043a\u0442\u043d\u044b\u0445 \u043c\u0438\u043a\u0441\u0438\u043d\u0430, \u043a\u043e\u0442\u043e\u0440\u044b\u0435 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u044e\u0442\u0441\u044f \u043f\u043e\u0447\u0442\u0438 \u0432\u0435\u0437\u0434\u0435:python# apps\/core\/models.pyimport uuidfrom django.db import modelsclass TimestampedModel(models.Model):    created_at = models.DateTimeField(auto_now_add=True)    updated_at = models.DateTimeField(auto_now=True)    class Meta:        abstract = Trueclass UUIDModel(models.Model):    id = models.UUIDField(        primary_key=True,        default=uuid.uuid4,        editable=False,    )    class Meta:        abstract = True# apps\/core\/models.py import uuid from django.db import models   class TimestampedModel(models.Model):     created_at = models.DateTimeField(auto_now_add=True)     updated_at = models.DateTimeField(auto_now=True)      class Meta:         abstract = True   class UUIDModel(models.Model):     id = models.UUIDField(         primary_key=True,         default=uuid.uuid4,         editable=False,     )      class Meta:         abstract = True\u0411\u043e\u043b\u044c\u0448\u0438\u043d\u0441\u0442\u0432\u043e \u043c\u043e\u0434\u0435\u043b\u0435\u0439 \u043d\u0430\u0441\u043b\u0435\u0434\u0443\u0435\u0442 \u043e\u0431\u0430. UUID \u043a\u0430\u043a \u043f\u0435\u0440\u0432\u0438\u0447\u043d\u044b\u0439 \u043a\u043b\u044e\u0447 \u2014 \u0432\u044b\u0431\u043e\u0440 \u0441\u0434\u0435\u043b\u0430\u043d \u043e\u0434\u043d\u0430\u0436\u0434\u044b, \u0447\u0442\u043e\u0431\u044b \u043d\u0435 \u0432\u043e\u0437\u0432\u0440\u0430\u0449\u0430\u0442\u044c\u0441\u044f \u043a \u043d\u0435\u043c\u0443 \u043f\u043e\u0442\u043e\u043c. \u041f\u0440\u0435\u0434\u0441\u043a\u0430\u0437\u0443\u0435\u043c\u044b\u0435 auto-increment ID \u0432 URL \u2014 \u043d\u0435 \u043b\u0443\u0447\u0448\u0430\u044f \u0438\u0434\u0435\u044f \u0441 \u0442\u043e\u0447\u043a\u0438 \u0437\u0440\u0435\u043d\u0438\u044f \u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u043e\u0441\u0442\u0438, \u0438 \u043f\u0435\u0440\u0435\u0435\u0437\u0436\u0430\u0442\u044c \u0441 integer \u043d\u0430 UUID \u0432 \u0441\u0443\u0449\u0435\u0441\u0442\u0432\u0443\u044e\u0449\u0435\u0439 \u0411\u0414 \u0431\u043e\u043b\u0435\u0437\u043d\u0435\u043d\u043d\u043e.\u041a\u0440\u043e\u043c\u0435 \u043c\u043e\u0434\u0435\u043b\u0435\u0439, \u0437\u0434\u0435\u0441\u044c\u00a0HealthCheckView\u00a0\u0438\u00a0ReadinessView\u00a0\u043f\u043e \u043f\u0443\u0442\u044f\u043c\u00a0\/health\/\u00a0\u0438\u00a0\/ready\/\u00a0\u2014 \u043d\u0443\u0436\u043d\u044b \u0434\u043b\u044f Docker healthcheck \u0438 Kubernetes liveness\/readiness probe.users\u041a\u0430\u0441\u0442\u043e\u043c\u043d\u0430\u044f \u043c\u043e\u0434\u0435\u043b\u044c\u00a0User\u00a0\u0441 email \u043a\u0430\u043a \u043e\u0441\u043d\u043e\u0432\u043d\u044b\u043c \u0438\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440\u043e\u043c:python# apps\/users\/models.pyclass User(UUIDModel, AbstractBaseUser, PermissionsMixin):    email = models.EmailField(unique=True)    full_name = models.CharField(max_length=255, blank=True)    avatar = models.ImageField(upload_to=&#187;avatars\/&#187;, blank=True, null=True)    is_active = models.BooleanField(default=True)    is_staff = models.BooleanField(default=False)    is_email_verified = models.BooleanField(default=False)    timezone = models.CharField(max_length=50, default=&#187;UTC&#187;)    stripe_customer_id = models.CharField(max_length=255, blank=True, null=True)    USERNAME_FIELD = &#171;email&#187;    REQUIRED_FIELDS = [&#171;full_name&#187;]    objects = CustomUserManager()    class Meta:        db_table = &#171;users_user&#187;        indexes = [            models.Index(fields=[&#171;email&#187;]),            models.Index(fields=[&#171;stripe_customer_id&#187;]),        ]# apps\/users\/models.py class User(UUIDModel, AbstractBaseUser, PermissionsMixin):     email = models.EmailField(unique=True)     full_name = models.CharField(max_length=255, blank=True)     avatar = models.ImageField(upload_to=&#187;avatars\/&#187;, blank=True, null=True)     is_active = models.BooleanField(default=True)     is_staff = models.BooleanField(default=False)     is_email_verified = models.BooleanField(default=False)     timezone = models.CharField(max_length=50, default=&#187;UTC&#187;)     stripe_customer_id = models.CharField(max_length=255, blank=True, null=True)      USERNAME_FIELD = &#171;email&#187;     REQUIRED_FIELDS = [&#171;full_name&#187;]      objects = CustomUserManager()      class Meta:         db_table = &#171;users_user&#187;         indexes = [             models.Index(fields=[&#171;email&#187;]),             models.Index(fields=[&#171;stripe_customer_id&#187;]),         ]CustomUserManager\u00a0\u043f\u0435\u0440\u0435\u043e\u043f\u0440\u0435\u0434\u0435\u043b\u044f\u0435\u0442\u00a0create_user\u00a0\u0438\u00a0create_superuser\u00a0\u043f\u043e\u0434 email-based \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044e. \u041f\u043e\u043b\u0435\u00a0stripe_customer_id\u00a0\u043d\u0430 \u043c\u043e\u0434\u0435\u043b\u0438 \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f \u2014 \u044d\u0442\u043e \u0434\u043b\u044f \u0441\u043b\u0443\u0447\u0430\u044f, \u043a\u043e\u0433\u0434\u0430 billing \u043f\u0440\u0438\u0432\u044f\u0437\u0430\u043d \u043a \u0444\u0438\u0437\u0438\u0447\u0435\u0441\u043a\u043e\u043c\u0443 \u043b\u0438\u0446\u0443, \u0430 \u043d\u0435 \u043a \u043a\u043e\u043c\u0430\u043d\u0434\u0435. \u0412 SaaS \u0441 \u043a\u043e\u043c\u0430\u043d\u0434\u0430\u043c\u0438 \u043e\u0431\u044b\u0447\u043d\u043e customer \u0441\u043e\u0437\u0434\u0430\u0451\u0442\u0441\u044f \u043d\u0430\u00a0Team, \u043d\u043e \u0438\u043c\u0435\u0442\u044c \u043f\u043e\u043b\u0435 \u043d\u0430 \u043e\u0431\u043e\u0438\u0445 \u043d\u0435 \u0432\u0440\u0435\u0434\u043d\u043e.\u0414\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u043e \u0432 \u044d\u0442\u043e\u043c app-\u0435:\u00a0EmailVerificationToken\u00a0\u0438\u00a0PasswordResetToken\u00a0\u2014 \u043c\u043e\u0434\u0435\u043b\u0438 \u0441 \u043f\u043e\u043b\u044f\u043c\u0438\u00a0token,\u00a0expires_at,\u00a0used_at. \u041d\u0438\u043a\u0430\u043a\u043e\u0433\u043e \u043a\u044d\u0448\u0430 \u0438\u043b\u0438 \u043e\u0442\u0434\u0435\u043b\u044c\u043d\u043e\u0433\u043e \u0445\u0440\u0430\u043d\u0438\u043b\u0438\u0449\u0430 \u2014 \u043f\u0440\u043e\u0441\u0442\u043e \u0441\u0442\u0440\u043e\u043a\u0438 \u0432 \u0411\u0414, \u0447\u0442\u043e \u0443\u0434\u043e\u0431\u043d\u043e \u043f\u0440\u0438 \u043e\u0442\u043b\u0430\u0434\u043a\u0435 \u0438 \u0430\u0443\u0434\u0438\u0442\u0435.teams\u0417\u0434\u0435\u0441\u044c \u0440\u0435\u0430\u043b\u0438\u0437\u043e\u0432\u0430\u043d\u0430 \u043c\u0443\u043b\u044c\u0442\u0438\u0430\u0440\u0435\u043d\u0434\u043d\u043e\u0441\u0442\u044c. \u0426\u0435\u043f\u043e\u0447\u043a\u0430 \u0432\u043b\u0430\u0434\u0435\u043d\u0438\u044f:textUser \u2192 TeamMembership \u2192 TeamUser \u2192 TeamMembership \u2192 TeamTeamMembership\u00a0\u2014 \u044d\u0442\u043e through-\u0442\u0430\u0431\u043b\u0438\u0446\u0430 \u0434\u043b\u044f M2M \u043c\u0435\u0436\u0434\u0443\u00a0User\u00a0\u0438\u00a0Team, \u0434\u043e\u043f\u043e\u043b\u043d\u0435\u043d\u043d\u0430\u044f \u043f\u043e\u043b\u0435\u043c\u00a0role. \u041d\u0430\u00a0Team\u00a0\u0445\u0440\u0430\u043d\u044f\u0442\u0441\u044f \u043b\u0438\u043c\u0438\u0442\u044b (max_members,\u00a0max_projects), \u043a\u043e\u0442\u043e\u0440\u044b\u0435 \u0437\u0430\u043f\u043e\u043b\u043d\u044f\u044e\u0442\u0441\u044f \u043f\u0440\u0438 \u0430\u043a\u0442\u0438\u0432\u0430\u0446\u0438\u0438 \u043f\u043e\u0434\u043f\u0438\u0441\u043a\u0438 \u0438\u0437\u00a0Plan. \u042d\u0442\u043e \u0434\u0435\u043d\u043e\u0440\u043c\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u044f, \u043d\u043e \u043d\u0430\u043c\u0435\u0440\u0435\u043d\u043d\u0430\u044f: \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0430 \u043b\u0438\u043c\u0438\u0442\u0430 \u043f\u0440\u0438 \u043a\u0430\u0436\u0434\u043e\u043c \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0438 \u043d\u0435 \u0434\u043e\u043b\u0436\u043d\u0430 \u0442\u0440\u0435\u0431\u043e\u0432\u0430\u0442\u044c JOIN \u0447\u0435\u0440\u0435\u0437 billing.TeamInvitation\u00a0\u2014 \u043c\u043e\u0434\u0435\u043b\u044c \u043f\u0440\u0438\u0433\u043b\u0430\u0448\u0435\u043d\u0438\u0439 \u0441\u043e \u0441\u0442\u0430\u0442\u0443\u0441\u0430\u043c\u0438\u00a0pending \/ accepted \/ expired \/ revoked. \u041f\u0440\u0438\u0433\u043b\u0430\u0448\u0435\u043d\u0438\u0435 \u2014 \u044d\u0442\u043e \u0437\u0430\u043f\u0438\u0441\u044c \u0432 \u0411\u0414 \u0441 \u0442\u043e\u043a\u0435\u043d\u043e\u043c \u0438 TTL, \u0430 \u043d\u0435 \u043f\u0440\u043e\u0441\u0442\u043e \u0441\u0441\u044b\u043b\u043a\u0430 \u0432 \u043f\u0438\u0441\u044c\u043c\u0435. \u042d\u0442\u043e \u043f\u043e\u0437\u0432\u043e\u043b\u044f\u0435\u0442 \u043e\u0442\u043e\u0437\u0432\u0430\u0442\u044c \u043f\u0440\u0438\u0433\u043b\u0430\u0448\u0435\u043d\u0438\u0435, \u043f\u043e\u0441\u043c\u043e\u0442\u0440\u0435\u0442\u044c \u0441\u043f\u0438\u0441\u043e\u043a \u043e\u0436\u0438\u0434\u0430\u044e\u0449\u0438\u0445 \u0438 \u043d\u0435 \u0431\u043e\u044f\u0442\u044c\u0441\u044f, \u0447\u0442\u043e \u043a\u0442\u043e-\u0442\u043e \u043f\u0435\u0440\u0435\u0445\u0432\u0430\u0442\u0438\u0442 \u0441\u0441\u044b\u043b\u043a\u0443 \u0434\u0432\u0443\u0445\u043b\u0435\u0442\u043d\u0435\u0439 \u0434\u0430\u0432\u043d\u043e\u0441\u0442\u0438.\u0421\u0438\u0433\u043d\u0430\u043b\u00a0post_save\u00a0\u043d\u0430\u00a0Team\u00a0\u043f\u0440\u043e\u0432\u0438\u0437\u0438\u043e\u043d\u0438\u0440\u0443\u0435\u0442 Stripe customer \u043f\u0440\u0438 \u0441\u043e\u0437\u0434\u0430\u043d\u0438\u0438 \u043a\u043e\u043c\u0430\u043d\u0434\u044b \u2014 \u043a\u043e\u043c\u0430\u043d\u0434\u0430 \u0441\u0440\u0430\u0437\u0443 \u0433\u043e\u0442\u043e\u0432\u0430 \u043a \u0431\u0438\u043b\u043b\u0438\u043d\u0433\u0443, \u0434\u0430\u0436\u0435 \u0435\u0441\u043b\u0438 \u043f\u043e\u0434\u043f\u0438\u0441\u043a\u0443 \u0430\u043a\u0442\u0438\u0432\u0438\u0440\u0443\u044e\u0442 \u043f\u043e\u0437\u0436\u0435.billing\u0427\u0435\u0442\u044b\u0440\u0435 \u043c\u043e\u0434\u0435\u043b\u0438:Plan\u00a0\u2014 \u0437\u0435\u0440\u043a\u0430\u043b\u0438\u0442 Stripe Product + Prices. \u0420\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u0443\u0435\u0442\u0441\u044f \u0447\u0435\u0440\u0435\u0437 Django Admin. \u0421\u043e\u0434\u0435\u0440\u0436\u0438\u0442\u00a0stripe_product_id,\u00a0stripe_price_id_monthly,\u00a0stripe_price_id_yearly, \u0430 \u0442\u0430\u043a\u0436\u0435 \u0434\u0435\u043d\u043e\u0440\u043c\u0430\u043b\u0438\u0437\u043e\u0432\u0430\u043d\u043d\u044b\u0435 \u043b\u0438\u043c\u0438\u0442\u044b:\u00a0max_members,\u00a0max_projects,\u00a0has_api_access.Subscription\u00a0\u2014 \u0441\u0432\u044f\u0437\u044c\u00a0Team\u00a0(OneToOne) \u0441\u00a0Plan\u00a0\u0438 \u0432\u0441\u0435\u043c\u0438 \u043f\u043e\u043b\u044f\u043c\u0438 Stripe subscription: \u0441\u0442\u0430\u0442\u0443\u0441, \u043f\u0435\u0440\u0438\u043e\u0434,\u00a0cancel_at_period_end, trial.Invoice\u00a0\u2014 \u0437\u0430\u043f\u0438\u0441\u044c \u043a\u0430\u0436\u0434\u043e\u0433\u043e \u0438\u043d\u0432\u043e\u0439\u0441\u0430 \u0441 \u0441\u0441\u044b\u043b\u043a\u0430\u043c\u0438 \u043d\u0430 PDF \u0438 hosted URL \u043e\u0442 Stripe.WebhookEvent\u00a0\u2014 \u0436\u0443\u0440\u043d\u0430\u043b \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u0430\u043d\u043d\u044b\u0445 \u0441\u043e\u0431\u044b\u0442\u0438\u0439 \u0434\u043b\u044f \u0438\u0434\u0435\u043c\u043f\u043e\u0442\u0435\u043d\u0442\u043d\u043e\u0441\u0442\u0438.notificationsEmailLog\u00a0\u2014 \u0430\u0443\u0434\u0438\u0442 \u043a\u0430\u0436\u0434\u043e\u0433\u043e \u0438\u0441\u0445\u043e\u0434\u044f\u0449\u0435\u0433\u043e \u043f\u0438\u0441\u044c\u043c\u0430: \u043a\u043e\u043c\u0443, \u0447\u0442\u043e, \u043a\u043e\u0433\u0434\u0430, \u0441\u0442\u0430\u0442\u0443\u0441. \u042d\u0442\u043e \u0438\u0437\u0431\u0430\u0432\u043b\u044f\u0435\u0442 \u043e\u0442 \u0432\u043e\u043f\u0440\u043e\u0441\u043e\u0432 &#171;\u0430 \u0434\u043e\u0448\u043b\u043e \u043b\u0438 \u043f\u0438\u0441\u044c\u043c\u043e&#187; \u0432 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u043a\u0435.\u0428\u0430\u0431\u043b\u043e\u043d\u044b \u043f\u0438\u0441\u0435\u043c \u0432\u00a0apps\/notifications\/templates\/notifications\/\u00a0\u2014 HTML \u0441 \u0431\u0430\u0437\u043e\u0432\u044b\u043c \u043b\u0435\u0439\u0430\u0443\u0442\u043e\u043c \u0438 \u043d\u0430\u0441\u043b\u0435\u0434\u043e\u0432\u0430\u043d\u0438\u0435\u043c:\u00a0welcome.html,\u00a0verify_email.html,\u00a0password_reset.html,\u00a0invitation.html,\u00a0billing_alert.html,\u00a0subscription_cancelled.html.api\u0417\u0434\u0435\u0441\u044c \u043d\u0435\u0442 \u0431\u0438\u0437\u043d\u0435\u0441-\u043b\u043e\u0433\u0438\u043a\u0438 \u2014 \u0442\u043e\u043b\u044c\u043a\u043e \u0438\u043d\u0444\u0440\u0430\u0441\u0442\u0440\u0443\u043a\u0442\u0443\u0440\u0430 DRF:router.py\u00a0\u2014\u00a0DefaultRouter\u00a0\u0441\u043e \u0432\u0441\u0435\u043c\u0438 \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u044b\u043c\u0438 ViewSet-\u0430\u043c\u0438throttling.py\u00a0\u2014\u00a0AnonRateThrottle,\u00a0UserRateThrottle,\u00a0BurstThrottlerenderers.py\u00a0\u2014\u00a0JSONRenderer\u00a0\u0441 \u043a\u043e\u043d\u0432\u0435\u0440\u0442\u043e\u043c\u00a0{&#171;data&#187;: &#8230;,&#8230;<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[],"tags":[],"class_list":["post-476455","post","type-post","status-publish","format-standard","hentry"],"_links":{"self":[{"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/posts\/476455","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=476455"}],"version-history":[{"count":0,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/posts\/476455\/revisions"}],"wp:attachment":[{"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=476455"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=476455"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=476455"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}