DEV Community

Michael Jones
Michael Jones

Posted on

Django to Phoenix - Part 4: Sessions

Background

I have a website built with Django and I would like to start writing parts of it in Phoenix.

Disclaimer

I am a beginner at Phoenix. I would like help & advice if I am doing something wrong.

Starting Point

In the last post we set up Phoenix inside our Docker container. We have nginx splitting traffic between Phoenix & Django. All requests go to Django except for the temporary /elixir path which we direct to Phoenix for experimenting.

In this post, we're going to look at accessing the Django sessions information so that our Phoenix app can know if the user is logged in or not. We're going to have to:

  • Check the session cookie
  • Check the sessions table in the database
  • Add some information to the request data to indicate if there is a current user session or not.
  • Displaying some indication of the session on the page.

Checking the Session Cookie

The official documentation tells us that Django uses cookies for tracking the session IDs. If we log in to our Django app in Chrome, open up the inspector, go to the Application tab and then click on the localhost entry under Cookies, then we can see a cookie called sessionid which contains a string.

I'm not cookie expert but my understanding is that any available cookies for the domain are automatically sent along with each standard web request. So when we navigate to /elixir the browser will be sending the same cookies over.

Phoenix is built on top of a layer called Plug. Looking at the documentation for Plug we can see that the cookies are available as an Elixir Map on the conn data. This is the standard connection data that is fed to functions that are dealing with requests.

In order to access that cookie we're going to set up a new Phoenix controller called TimetableWeb.Session. To do this we create a file called session.ex in the directory lib/timetable_web/controllers/ in our Phoenix project. It will have the following contents:

defmodule TimetableWeb.Session do
  import Plug.Conn

  def init(_) do
  end

  def call(conn, _) do
    case conn.cookies["sessionid"] do
      nil ->
        IO.puts "No sessionid found"
      session_id ->
        IO.puts "Session ID: #{session_id}"
    end

    # Return conn
    conn
  end
end
Enter fullscreen mode Exit fullscreen mode

This is standard Plug format. For the moment, we don't need to do anything in the init function but it still required. The call function requires two arguments in order to receive the data from init but we don't need it so we use a simple _ as the argument name.

As for the rest of the Plug, we look into the conn.cookies Map for the sessionid entry and pattern match on the result. If we get nil then we know we don't have an active session at the moment. If we get something other than nil then we know we must have the session ID. For the moment, we print an appropriate message and return the conn at the end of the function. In Elixir the result of the last expression is returned from the function. There is no need for an explicit return keyword in that situation.

Now we need to add our plug into the Phoenix request pipeline so that it can process incoming requests. To do this we edit our lib/timetable_web/router.ex file with the following change:

   pipeline :browser do
     plug :accepts, ["html"]
     plug :fetch_session
     plug :fetch_flash
     plug :protect_from_forgery
     plug :put_secure_browser_headers
+    plug TimetableWeb.Session, repo: Timetable.Repo
   end

   pipeline :api do
     plug :accepts, ["json"]
   end
Enter fullscreen mode Exit fullscreen mode

Here we are adding our plug to the end of the browser pipeline. We're not using the api pipeline at the moment so we ignore that.

Now, if we go to our Django app, log in and visit /elixir then we see:

[info] GET /elixir
Session ID: boj26dqs1v0okevigm2vnsu11gephx21
Enter fullscreen mode Exit fullscreen mode

In our console output from the Phoenix app inside our Docker container.

If we now go to our Django app, log out and visit /elixir, then we see:

[info] GET /elixir
No sessionid found
Enter fullscreen mode Exit fullscreen mode

So we seem to be successfully getting the session cookie.

Checking the Sessions Database Table

Django doesn't store meaningful data in the session cookie. It is just an ID to access the data from the sessions table in the database. The documentation tells us that the session data is stored in a table called django_session. If we run python manage.py dbshell and in the Postgresql prompt run: \d django_session then we get a description of the table:

            Table "public.django_session"
    Column    |           Type           | Modifiers 
-------------------+--------------------------+-----------
 session_key  | character varying(40)    | not null
 session_data | text                     | not null
 expire_date  | timestamp with time zone | not null
Indexes:
    "django_session_pkey" PRIMARY KEY, btree (session_key)
    "django_session_expire_date" btree (expire_date)
    "django_session_session_key_like" btree (session_key varchar_pattern_ops)
Enter fullscreen mode Exit fullscreen mode

We can see that there are 3 columns. The session_key most likely maps to our session_id. The session_data most likely includes the useful information about the session. Finally, there is an expire_date to indicate when the session should be considered 'stale' and no longer honoured. We can also see that the session_key field is the primary key for this table.

We want to get to a position in which our Phoenix app can check this database table to retrieve the session information. Phoenix uses a layer called Ecto to model database tables and talk to databases. We are going to use a mix task to help generate Ecto schemas. The command we're going to run is:

mix phx.gen.schema Session sessions session_key:string session_data:string expire_date:date
Enter fullscreen mode Exit fullscreen mode

This generates an Ecto schema that is almost correct for what we need. The problem is that it assumes that we're going to want to have an extra id column to use as the primary key. This makes sense for a lot of tables but not our django_session table where the primary key is the session_key column. So we have to adjust the final schema to inform Ecto of our preferred primary key. Here is what it is going to look like:

defmodule Timetable.Session do
  use Ecto.Schema
  import Ecto.Changeset
  alias Timetable.Session

  @primary_key {:session_key, :string, autogenerate: false}

  schema "django_session" do
    field :session_data, :string
    field :expire_date, :utc_datetime
  end

  @doc false
  def changeset(%Session{} = session, attrs) do
    session
    |> cast(attrs, [:session_key, :session_data, :expire_date])
    |> validate_required([:session_key, :session_data, :expire_date])
  end
end
Enter fullscreen mode Exit fullscreen mode

To check if this works, we can try it in the Elixir interactive shell: iex. If we run iex -S mix from Phoenix project root directory, then we can do:

iex(1)> Timetable.Session |> Timetable.Repo.get_by(session_key: "boj26dqs1v0okevigm2vnsu11gephx21")                                                       
[debug] QUERY OK source="django_session" db=131.4ms                          
SELECT d0."session_key", d0."session_data", d0."expire_date" FROM "django_session" AS d0 WHERE (d0."session_key" = $1) ["boj26dqs1v0okevigm2vnsu11gephx21"]                                     
%Timetable.Session{__meta__: #Ecto.Schema.Metadata<:loaded, "django_session">,                                                                            
 expire_date: #DateTime<2017-11-21 08:31:06.774119Z>,                        
 session_data: "ODNjNWFkMDJh....cl9pZCI6IjEifQ==",  
 session_key: "boj26dqs1v0okevigm2vnsu11gephx21"
Enter fullscreen mode Exit fullscreen mode

We can see that we received a record with the session data that we're interested in.

If we want to check the expire_date as well we can do this using the Ecto query syntax:

iex(1)> import Ecto.Query, only: [from: 2]
Ecto.Query
iex(2)> now = DateTime.utc_now()
#DateTime<2017-11-12 10:31:16.153448Z>
iex(3)> (from s in Timetable.Session, where: s.session_key == "boj26dqs1v0okevigm2vnsu11gephx21" and s.expire_date >= ^now) |> Timetable.Repo.one
[debug] QUERY OK source="django_session" db=24.0ms
SELECT d0."session_key", d0."session_data", d0."expire_date" FROM "django_session" AS d0 WHERE ((d0."session_key" = 'boj26dqs1v0okevigm2vnsu11gephx21') AND (d0."expire_date" >= $1)) [{{2017, 11, 12}, {10, 31, 16, 153448}}]
%Timetable.Session{__meta__: #Ecto.Schema.Metadata<:loaded, "django_session">,
  expire_date: #DateTime<2017-11-21 08:31:06.774119Z>,
  session_data: "ODNjNWFkMDJh....cl9pZCI6IjEifQ==",
  session_key: "boj26dqs1v0okevigm2vnsu11gephx21"}
Enter fullscreen mode Exit fullscreen mode

And to check that it returns nothing when the session has expired we can do:

iex(1)> import Ecto.Query, only: [from: 2]
Ecto.Query
iex(2)> {:ok, later, 0} = DateTime.from_iso8601("2017-11-22T00:00:00Z")                                                                            
{:ok, #DateTime<2017-11-22 00:00:00Z>, 0}
iex(3)> (from s in Timetable.Session, where: s.session_key == "boj26dqs1v0okevigm2vnsu11gephx21" and s.expire_date >= ^later) |> Timetable.Repo.one
[debug] QUERY OK source="django_session" db=2.9ms
SELECT d0."session_key", d0."session_data", d0."expire_date" FROM "django_session" AS d0 WHERE ((d0."session_key" = 'boj26dqs1v0okevigm2vnsu11gephx21') AND (d0."expire_date" >= $1)) [{{2017, 11, 22}, {0, 0, 0, 0}}]
nil
Enter fullscreen mode Exit fullscreen mode

We get back nil when providing a date after the session expire_date.

Adding Session Information to the Request

Now that we have a way to extract information from the session table, we can update our Session plug to check for a valid session. We're going to update it to look like this:

defmodule TimetableWeb.Session do
  import Plug.Conn
  import Ecto.Query, only: [from: 2]

  def init(opts) do
    Keyword.fetch!(opts, :repo)
  end

  def call(conn, repo) do
    now = DateTime.utc_now()
    case conn.cookies["sessionid"] do
      nil ->
        assign(conn, :logged_in, false)
      session_key ->
        case repo.one(from s in Timetable.Session, where: s.session_key == ^session_key and s.expire_date >= ^now) do
          nil ->
            assign(conn, :logged_in, false)
          _ ->
            assign(conn, :logged_in, true)
        end
    end
  end
end

Enter fullscreen mode Exit fullscreen mode

We have done two main adjustments:

  1. The init function has been updated to look for and return the :repo reference from the opts. The call function has been updated to receive the repo as the second argument.
  2. If the call function has found a session key in the cookie, we check the sessions table for that session. If the session is valid, then we return the conn data with a new logged_in field set to true. In both of the other cases where no valid cookie or session is found, we return the conn with a logged_in field set to false.

This isn't very advanced yet. A normal system would want to access the session_data from the session table as well but we'll leave that for the next post.

Displaying Session Information on the Page

For the final step, we want to display whether or not the user is logged in. We're going to keep it very basic for now and update the template for the Phoenix landing page which is lib/timetable_web/templates/page/index.html.eex. We're going to make the follow change:

 <div class="row marketing">
+
+  <div>
+    <%= if @logged_in do %>
+      Logged In!
+    <% else %>
+      Not Logged In
+    <% end %>
+  </div>
+
   <div class="col-lg-6">
     <h4>Resources</h4>
Enter fullscreen mode Exit fullscreen mode

This uses EEx - Embedded Elixir - to display different content depending on the value of logged_in. As a field on conn, the logged_in value is accessible in our templates.

Now when we log in to our Django app and navigate to our /elixir page, we're going to see Logged In! displayed to us on the page. And if we log out of our Django app and come back to this page we see Not Logged In instead.

Conclusion

We have our Phoenix app reading the session information from the Django sessions table. We are able to update the web page in a basic manner to reflect the logged-in status of the current user.

In the next post, we'll extend this approach to also check the user table for the user name.

Top comments (3)

Collapse
 
bhaugen profile image
Bob Haugen

Heya Michael, thanks for the fascinating series of articles. We're in a slightly different place. We got some Django code, and also an already-implemented-and-deployed Phoenix app running on Cowboy. Did you ever consider trying to run Django on Cowboy instead of Phoenix on Nginx? Or is that just crazy talk?

Collapse
 
michaeljones profile image
Michael Jones

Hey, thanks for the question. Honestly, I don't know enough about Cowboy and web servers in general to know if that is crazy.

When I started on this path I tried to figure out if I could have the Phoenix app in front of the Django app and any routes that the Phoenix app didn't match would 'fall-through' to the Django one. Then I could just implement Phoenix routes as I saw fit and not have to worry about the Nginx layer to switch between them. I asked on the Elixir subreddit and the responders didn't seem to suggest that as a way forward so I've ended up with Nginx at the moment. Maybe I can figure it out and switch at some point.

I currently run the Django app via Gunicorn. I'm not really sure what it would mean to run Django on Cowboy.

Sorry I'm not in a better position to advise. I ended up taking a break from the project but have recently picked it up so I'll be building a bit more experience with path I've taken as I go.

Collapse
 
bhaugen profile image
Bob Haugen

Thanks for the response. From some other web-searching, looks like Nginx is a common connector for both Phoenix and Django, but did not find anybody trying to run Django from Cowboy.