############################################################# Django Sorcery - Django Framework integration with SQLAlchemy #############################################################
|Build Status| |Read The Docs| |PyPI version| |Coveralls Status| |Black|
SQLAlchemy is an excellent orm. And Django is a great framework, until you decide not to use Django ORM. This library provides utilities, helpers and configurations to ease the pain of using SQLAlchemy with Django. It aims to provide a similar development experience to building a Django application with Django ORM, except with SQLAlchemy.
::
pip install django-sorcery
Lets start by creating a site:
.. code:: console
$ django-admin startproject mysite
And lets create an app:
.. code:: console
$ cd mysite $ python manage.py startapp polls
This will create a polls app with standard django app layout:
.. code:: console
$ tree . ├── manage.py ├── polls │ ├── admin.py │ ├── apps.py │ ├── init.py │ ├── migrations │ │ └── init.py │ ├── models.py │ ├── tests.py │ └── views.py └── mysite ├── init.py ├── settings.py ├── urls.py └── wsgi.py
3 directories, 12 files
And lets add our polls
app and django_sorcery
in INSTALLED_APPS
in mysite/settings.py
:
.. code:: python
INSTALLED_APPS = [ 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', 'django_sorcery', 'polls.apps.PollsConfig', ]
Now we're going to make a twist and start building our app with sqlalchemy
. Lets define our models in
polls/models.py
:
.. code:: python
from django_sorcery.db import databases
db = databases.get("default")
class Question(db.Model): pk = db.Column(db.Integer(), autoincrement=True, primary_key=True) question_text = db.Column(db.String(length=200)) pub_date = db.Column(db.DateTime())
class Choice(db.Model): pk = db.Column(db.Integer(), autoincrement=True, primary_key=True) choice_text = db.Column(db.String(length=200)) votes = db.Column(db.Integer(), default=0)
question = db.ManyToOne(Question, backref=db.backref("choices", cascade="all, delete-orphan"))
Now that we have some models, lets create a migration using alembic
integration:
.. code:: console
$ python manage.py sorcery revision -m "Add question and poll models" polls Generating ./polls/migrations/3983fc419e10_add_question_and_poll_models.py ... done
Let's take a look at the generated migration file ./polls/migrations/3983fc419e10_add_question_and_poll_models.py
:
.. code:: python
""" Add question and poll models
Revision ID: 3983fc419e10 Revises: Create Date: 2019-04-16 20:57:48.154179 """
from alembic import op import sqlalchemy as sa
revision = '3983fc419e10' down_revision = None branch_labels = None depends_on = None
def upgrade():
op.create_table('question',
sa.Column('pk', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('question_text', sa.String(length=200), nullable=True),
sa.Column('pub_date', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('pk')
)
op.create_table('choice',
sa.Column('pk', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('choice_text', sa.String(length=200), nullable=True),
sa.Column('votes', sa.Integer(), nullable=True),
sa.Column('question_pk', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['question_pk'], ['question.pk'], ),
sa.PrimaryKeyConstraint('pk')
)
# ### end Alembic commands ###
def downgrade():
op.drop_table('choice')
op.drop_table('question')
# ### end Alembic commands ###
Let's take a look at generated sql:
.. code:: console
$ python manage.py sorcery upgrade --sql polls
CREATE TABLE alembic_version_polls ( version_num VARCHAR(32) NOT NULL, CONSTRAINT alembic_version_polls_pkc PRIMARY KEY (version_num) );
-- Running upgrade -> d7d86e07cc8e
CREATE TABLE question ( pk INTEGER NOT NULL, question_text VARCHAR(200), pub_date DATETIME, PRIMARY KEY (pk) );
CREATE TABLE choice ( pk INTEGER NOT NULL, choice_text VARCHAR(200), votes INTEGER, question_pk INTEGER, PRIMARY KEY (pk), FOREIGN KEY(question_pk) REFERENCES question (pk) );
INSERT INTO alembic_version_polls (version_num) VALUES ('d7d86e07cc8e');
Let's bring our db up to date:
.. code:: console
$ python manage.py sorcery upgrade Running migrations for polls on database default
Right now, we have enough to hop in django shell:
.. code:: console
$ python manage.py shell
from polls.models import Choice, Question, db # Import the model classes and the db
Choice.query.all() [] Question.query.all() []
from django.utils import timezone q = Question(question_text="What's new?", pub_date=timezone.now()) q Question(pk=None, pub_date=datetime.datetime(2018, 5, 19, 0, 54, 20, 778186, tzinfo=
), question_text="What's new?")
db.add(q)
db.new IdentitySet([Question(pk=None, pub_date=datetime.datetime(2018, 5, 19, 0, 54, 20, 778186, tzinfo=
), question_text="What's new?")])
db.flush()
q.pk 1
q.question_text = "What's up?" db.flush()
Question.objects <django_sorcery.db.query.Query at 0x7feb1c7899e8> Question.query <django_sorcery.db.query.Query at 0x7feb1c9377f0>
Question.objects.all() [Question(pk=1, pub_date=datetime.datetime(2018, 5, 19, 0, 54, 20, 778186, tzinfo=
), question_text="What's up?")] exit()
Let's add a couple of views in polls/views.py
, starting with a list view:
.. code:: python
from django.shortcuts import render from django.template import loader from django.http import HttpResponseRedirect from django.urls import reverse
from django_sorcery.shortcuts import get_object_or_404
from .models import Question, Choice, db
def index(request): latest_question_list = Question.objects.order_by(Question.pub_date.desc())[:5] context = {'latest_question_list': latest_question_list} return render(request, 'polls/index.html', context)
def detail(request, question_id): question = get_object_or_404(Question, pk=question_id) return render(request, 'polls/detail.html', {'question': question})
def results(request, question_id): question = get_object_or_404(Question, pk=question_id) return render(request, 'polls/results.html', {'question': question})
def vote(request, question_id): question = get_object_or_404(Question, pk=question_id)
selected_choice = Choice.query.filter(
Choice.question == question,
Choice.pk == request.POST['choice'],
).one_or_none()
if not selected_choice:
return render(request, 'polls/detail.html', {
'question': question,
'error_message': "You didn't select a choice.",
})
selected_choice.votes += 1
db.flush()
return HttpResponseRedirect(reverse('polls:results', args=(question.pk,)))
and register the view in polls/urls.py
:
.. code:: python
from django.urls import path
from . import views
app_name = 'polls'
urlpatterns = [
path('', views.index, name='index'),
path('
and register the SQLAlchemyMiddleware
to provide unit-of-work per request pattern:
.. code:: python
MIDDLEWARE = [ 'django_sorcery.db.middleware.SQLAlchemyMiddleware',
]
and add some templates:
polls/templates/polls/index.html
:
.. code:: html
{% if latest_question_list %}
{% else %}
No polls are available.
{% endif %}
polls/templates/polls/detail.html
:
.. code:: html
{% if error_message %}
{{ error_message }}
{% endif %}polls/templates/polls/results.html
:
.. code:: html
This is all fine but we can do one better using generic views. Lets adjust our views in polls/views.py
:
.. code:: python
from django.shortcuts import render from django.http import HttpResponseRedirect from django.urls import reverse
from django_sorcery.shortcuts import get_object_or_404 from django_sorcery import views
from .models import Question, Choice, db
class IndexView(views.ListView): template_name = 'polls/index.html' context_object_name = 'latest_question_list'
def get_queryset(self):
return Question.objects.order_by(Question.pub_date.desc())[:5]
class DetailView(views.DetailView): model = Question session = db template_name = 'polls/detail.html'
class ResultsView(DetailView): template_name = 'polls/results.html'
def vote(request, question_id): question = get_object_or_404(Question, pk=question_id)
selected_choice = Choice.query.filter(
Choice.question == question,
Choice.pk == request.POST['choice'],
).one_or_none()
if not selected_choice:
return render(request, 'polls/detail.html', {
'question': question,
'error_message': "You didn't select a choice.",
})
selected_choice.votes += 1
db.flush()
return HttpResponseRedirect(reverse('polls:results', args=(question.pk,)))
and adjust the polls/urls.py
like:
.. code:: python
from django.urls import path
from . import views
app_name = 'polls'
urlpatterns = [
path('', views.IndexView.as_view(), name='index'),
path('
The default values for template_name
and context_object_name
are similar to django's generic views. If we
handn't defined those the default for template names would've been polls/question_detail.html
and
polls/question_list.html
for the detail and list template names, and question
and question_list
for context
names for detail and list views.
This is all fine but we can even do one better using a viewset. Lets adjust our views in polls/views.py
:
.. code:: python
from django.http import HttpResponseRedirect from django.urls import reverse, reverse_lazy
from django_sorcery.routers import action from django_sorcery.viewsets import ModelViewSet
from .models import Question, Choice, db
class PollsViewSet(ModelViewSet): model = Question fields = "all" destroy_success_url = reverse_lazy("polls:question-list")
def get_success_url(self):
return reverse("polls:question-detail", kwargs={"pk": self.object.pk})
@action(detail=True)
def results(self, request, *args, **kwargs):
return self.retrieve(request, *args, **kwargs)
@action(detail=True, methods=["POST"])
def vote(self, request, *args, **kwargs):
self.object = self.get_object()
selected_choice = Choice.query.filter(
Choice.question == self.object, Choice.pk == request.POST.get("choice")
).one_or_none()
if not selected_choice:
context = self.get_detail_context_data(object=self.object)
context["error_message"] = "You didn't select a choice."
self.action = "retrieve"
return self.render_to_response(context)
selected_choice.votes += 1
db.flush()
return HttpResponseRedirect(reverse("polls:question-results", args=(self.object.pk,)))
And adjusting our polls/urls.py
like:
.. code:: python
from django.urls import path, include
from django_sorcery.routers import SimpleRouter
from . import views
router = SimpleRouter() router.register("", views.PollsViewSet)
app_name = "polls" urlpatterns = [path("", include(router.urls))]
With these changes we'll have the following urls:
.. code:: console
$ ./manage.py run show_urls
/polls/ polls.views.PollsViewSet polls:question-list
/polls/
This will map the following operations to following actions on the viewset:
====== ======================== =============== =============== Method Path Action Route Name ====== ======================== =============== =============== GET /polls/ list question-list POST /polls/ create question-list GET /polls/new/ new question-new GET /polls/1/ retrieve question-detail POST /polls/1/ update question-detail PUT /polls/1/ update question-detail PATCH /polls/1/ update question-detail DELETE /polls/1/ destroy question-detail GET /polls/1/edit/ edit question-edit GET /polls/1/delete/ confirm_destoy question-delete POST /polls/1/delete/ destroy question-delete ====== ======================== =============== ===============
Now, lets add an inline formset to be able to add choices to questions, adjust polls/views.py
:
.. code:: python
from django.http import HttpResponseRedirect from django.urls import reverse, reverse_lazy
from django_sorcery.routers import action from django_sorcery.viewsets import ModelViewSet from django_sorcery.formsets import inlineformset_factory
from .models import Question, Choice, db
ChoiceFormSet = inlineformset_factory(relation=Question.choices, fields=(Choice.choice_text.key,), session=db)
class PollsViewSet(ModelViewSet): model = Question fields = (Question.question_text.key, Question.pub_date.key) destroy_success_url = reverse_lazy("polls:question-list")
def get_success_url(self):
return reverse("polls:question-detail", kwargs={"pk": self.object.pk})
def get_form_context_data(self, **kwargs):
kwargs["choice_formset"] = self.get_choice_formset()
return super().get_form_context_data(**kwargs)
def get_choice_formset(self, instance=None):
if not hasattr(self, "_choice_formset"):
instance = instance or self.object
self._choice_formset = ChoiceFormSet(
instance=instance, data=self.request.POST if self.request.POST else None
)
return self._choice_formset
def process_form(self, form):
if form.is_valid() and self.get_choice_formset(instance=form.instance).is_valid():
return self.form_valid(form)
return form.invalid(self, form)
def form_valid(self, form):
self.object = form.save()
self.object.choices = self.get_choice_formset().save()
db.flush()
return HttpResponseRedirect(self.get_success_url())
@action(detail=True)
def results(self, request, *args, **kwargs):
return self.retrieve(request, *args, **kwargs)
@action(detail=True, methods=["POST"])
def vote(self, request, *args, **kwargs):
self.object = self.get_object()
selected_choice = Choice.query.filter(
Choice.question == self.object, Choice.pk == request.POST.get("choice")
).one_or_none()
if not selected_choice:
context = self.get_detail_context_data(object=self.object)
context["error_message"] = "You didn't select a choice."
self.action = "retrieve"
return self.render_to_response(context)
selected_choice.votes += 1
db.flush()
return HttpResponseRedirect(reverse("polls:question-results", args=(self.object.pk,)))
And add choice_formset
in the polls/templates/question_edit.html
and polls/templates/question_edit.html
.. code:: html
<form ... > ... {{ choice_formset }} ...
.. |Build Status| image:: https://github.com/shosca/django-sorcery/workflows/Build/badge.svg?branch=master :target: https://github.com/shosca/django-sorcery/actions?query=workflow%3ABuild+branch%3Amaster .. |Read The Docs| image:: https://readthedocs.org/projects/django-sorcery/badge/?version=latest :target: http://django-sorcery.readthedocs.io/en/latest/?badge=latest .. |PyPI version| image:: https://badge.fury.io/py/django-sorcery.svg :target: https://badge.fury.io/py/django-sorcery .. |Coveralls Status| image:: https://coveralls.io/repos/github/shosca/django-sorcery/badge.svg?branch=master :target: https://coveralls.io/github/shosca/django-sorcery?branch=master .. |Black| image:: https://img.shields.io/badge/code%20style-black-000000.svg :target: https://github.com/ambv/black