lib/odoo.ex

defmodule Odoo do
  @moduledoc """
  Library to access Odoo JSON-RPC API from Elixir.

  Provides the following methods for interacting with Odoo:

  - login
  - search
  - search_read
  - read
  - read_group
  - create
  - write
  - delete
  - execute

  """

  @doc """

  - Login in Odoo and set session_id for future calls

  ### Params (required)
  - user: string  Odoo user
  - password: string  Odoo password
  - database: string  Odoo database
  - url: string  Odoo url, http or https

  ### Example

  ```
  iex> {:ok, odoo} = Odoo.login(
    "admin", "admin", "mydatabasename", "https://mydatabasename.odoo.com")
  {:ok,
  %Odoo.Session{
    cookie: "session_id=c8e544d0b305920adgsfdfdsa7b0cfe; Expires=Fri, 06-May-2022 23:16:12 GMT; Max-Age=7776000; HttpOnly; Path=/",
    database: "mydatabasename",
    password: "admin",
    url: "https://mydatabasename.odoo.com",
    user: "admin",
    user_context: %{"lang" => "en_US", "tz" => "Asia/Calcutta", "uid" => 2}
  }}

  ```
  """
  @spec login(String.t(), String.t(), String.t(), String.t()) ::
          {:ok, %Odoo.Session{}} | {:error, String.t()}
  def login(user, password, database, url) do
    case check_params(user, password, database, url) do
      {:ok, _} ->
        Odoo.Core.login(user, password, database, url)

      {:error, message} ->
        {:error, message}
    end
  end

  @doc """

  ### Example

  - Search and read with default options (limit, domain, fields, offset and order)

  ```{:ok, res} = Odoo.search_read(odoo, "res.partner")```

  - Put options to tune the query:

  ```elixir
  {:ok, partners} = Odoo.search_read(
    odoo,
    "res.partner",
    [
      limit: 1,
      domain: [["name", "ilike", "Antonio"]],
      fields: ["name", "street"],
      offset: 11,
      order: "name asc"])

  {:ok,
  [
    %{
      "id" => 226,
      "name" => "Antonio Fulanito de tal",
      "street" => "Calle principal 1"
    }
    ]}
    ```

    - Search and read active and archived records (by default odoo only return active records)

    ```elixir
    {:ok, partners} = Odoo.search_read(
      odoo,
      "res.partner", [
        fields: ["name"],
        offset: 0,
            limit: 10,
            order: "id",
            domain: [["active", "in", [true,false]]]
          ]
          )
          ```

  """
  @type option ::
          {:limit, non_neg_integer()}
          | {:offset, non_neg_integer()}
          | {:order, String.t()}
          | {:fields, [String.t(), ...]}
          | {:domain, [list(), ...]}
  @spec search_read(%Odoo.Session{}, String.t(), [option]) ::
          {:ok, %Odoo.Result{}} | {:error, String.t()}
  def search_read(odoo = %Odoo.Session{}, model, opts \\ []) do
    Odoo.Core.search_read(odoo, model, opts)
  end

  @doc """
  - Search by domain. Return single id or id list.

  ### Params

  - Arguments in keyword list format.

  - Required opts:
  - domain: list of list

  - Optional arguments
  - limit: int, max number of rows to return from odoo
  - offset: int, offset over the default values to return

  ### Example
  ```elixir
  iex>  {:ok, partner_ids} = Odoo.search(
  odoo, "res.partner",
  [ domain:
      [
        ["name", "ilike", "Antonia%"],
        ["customer", "=", true],
        ["create_date",">=","2021-06-01"]
      ],
      limit: 5,
      offset: 10,
      order: "name"
  ])
  {:ok,
    %Odoo.Result{
      data: '[44]',
      model: "res.partner",
      opts: [domain: [["name", "ilike", "Lorraine%"], ["customer", "=", true],
        ["create_date",">=","2021-06-01"]]]
    }}

  ```

  - Return one value is a single integer for the id
  - Return more than one value is a list of integers
  - Can also return {:error, message} if the operation fails.
  """
  @type option_valid_for_search ::
          {:limit, non_neg_integer()}
          | {:offset, non_neg_integer()}
          | {:order, String.t()}
          | {:domain, [list(), ...]}
  @spec search(%Odoo.Session{}, String.t(), [option_valid_for_search]) ::
          {:ok, %Odoo.Result{}} | {:error, String.t()}
  def search(odoo = %Odoo.Session{}, model, opts \\ []) do
    Odoo.Core.search(odoo, model, opts)
  end

  @doc """
  - Create objects
  - Return {:ok, new_object_id} or {:error, message}

  ## Example

        iex> {:ok, product_id} = Odoo.create(
          odoo, "product.product", [name: "mi mega producto3"])
      {:ok, 63}

  You can specify the context language to create the object in the desired language.
  ```elixir
  iex(44)> {:ok, result} = Odoo.create(odoo, "product.product", [name: "Product2"], [lang: "en_US"])
  {:ok,
  %Odoo.Result{data: 185, model: "product.product", opts: [name: "Product2"]}}
  ```

  """
  @type odoo_fields :: keyword()
  @spec create(%Odoo.Session{}, String.t(), [odoo_fields]) ::
          {:ok, integer()} | {:error, String.t()}
  def create(odoo = %Odoo.Session{}, model, opts \\ []) do
    Odoo.Core.create(odoo, model, opts)
  end

  @doc """
  - Read objects by id
  - Return {:ok, objects_list} or {:error, message}

  ### Example

  ```elixir
  iex> {:ok, product} = Odoo.read(
  odoo, "product.product", [63], [fields: ["name", "categ_id"]])

  {:ok,
  %Odoo.Result{
    data: [
      %{"id" => 63, "name" => "mi mega producto3", "categ_id" => [1, "All"]}
    ],
    model: "product.product",
    opts: [fields: ["name", "categ_id"]]
  }}

  ```
  """
  @type option_valid_for_read :: {:fields, [String.t(), ...]}
  @spec read(%Odoo.Session{}, String.t(), [non_neg_integer(), ...], [option_valid_for_read]) ::
          {:ok, %Odoo.Result{}} | {:error, String.t()}
  def read(odoo = %Odoo.Session{}, model, object_id, opts \\ []) do
    Odoo.Core.read(odoo, model, object_id, opts)
  end

  @doc """
    - Read and group objects

    ### Params

    - :fields
    - :domain
    - :groupby
    - :lazy
    - :orderby
    - :offset

  ### Example

  ```elixir
  iex> {:ok, result} = Odoo.read_group(
  odoo, "account.invoice", [
    domain: [["date_invoice", ">=", "2021-11-01"]],
    groupby: ["date_invoice:month"],
    fields: ["number", "partner_id"],
    limit: 2,
    lazy: true])

    {:ok,
     %Odoo.Result{
      data: [
    %{
      "__domain" => [
     "&",
     "&",
     ["date_invoice", ">=", "2022-01-01"],
     ["date_invoice", "<", "2022-02-01"],
     ["date_invoice", ">=", "2021-11-01"]
     ],
     "date_invoice:month" => "enero 2022",
     "date_invoice_count" => 61
     },
     %{
       "__domain" => [
         "&",
         "&",
         ["date_invoice", ">=", "2022-02-01"],
         ["date_invoice", "<", "2022-03-01"],
         ["date_invoice", ">=", "2021-11-01"]
         ],
      "date_invoice:month" => "febrero 2022",
      "date_invoice_count" => 32
    }
    ]}}

  ```

  """
  @type option_valid_for_read_group ::
          {:domain, [list(), ...]}
          | {:fields, [String.t(), ...]}
          | {:groupby, [String.t(), ...]}
          | {:limit, non_neg_integer()}
          | {:lazy, bool()}
  @spec read_group(%Odoo.Session{}, String.t(), [option_valid_for_read]) ::
          {:ok, %Odoo.Result{}} | {:error, String.t()}
  def read_group(odoo = %Odoo.Session{}, model, opts \\ []) do
    Odoo.Core.read_group(odoo, model, opts)
  end

  @doc """
  - Update objects by id

  ### Example

  ```elixir
  iex> {:ok, result}=Odoo.write odoo, "product.product", [63], [name: "Mega Pro 3"]
  {:ok,
  %Odoo.Result{
   data: true,
   model: "product.product",
   opts: [name: "Mega Pro 3"]
  }}

  Example adding a many2many value to an odoo object:

  category_to_link_id = 9
  {:ok, result} = Odoo.write(
      odoo,
      "res.partner",
      partner_ids,
      [category_id: [[4, category_to_link_id]] ])
  ```
  """
  @spec write(%Odoo.Session{}, String.t(), list(non_neg_integer()), keyword()) ::
          {:ok, %Odoo.Result{}} | {:error, String.t()}
  def write(odoo = %Odoo.Session{}, model, object_id, fields \\ []) do
    Odoo.Core.write(odoo, model, object_id, fields)
  end

  @doc """
    - Delete objects by id

    ### Example

    ```elixir
  iex(10)> {:ok, result} = Odoo.delete odoo, "product.template", [116]
  {:ok, %Odoo.Result{data: true, model: "product.template", opts: 't'}}
      ```

  """
  def delete(odoo = %Odoo.Session{}, model, object_id) do
    Odoo.Core.delete(odoo, model, object_id)
  end

  @doc """
  Pagination over results in search_read (call to api odoo)

  ### Example

  ```elixir
  iex> {:ok, result} = Odoo.search_read(
    ...>      odoo, "product.product", limit: 5, fields: ["name"], order: "id asc")
    {:ok,
    %Odoo.Result{
      data: [
      %{"id" => 1, "name" => "Restaurant Expenses"},
      %{"id" => 2, "name" => "Hotel Accommodation"},
      %{"id" => 3, "name" => "Virtual Interior Design"},
      %{"id" => 4, "name" => "Virtual Home Staging"},
      %{"id" => 5, "name" => "Office Chair"}
      ],
      model: "product.product",
      opts: [limit: 5, fields: ["name"], order: "id asc"]
  }}
  iex> {:ok, result2} = Odoo.next(odoo, result)
  {:ok,
  %Odoo.Result{
    data: [
      %{"id" => 6, "name" => "Office Lamp"},
      %{"id" => 7, "name" => "Office Design Software"},
      %{"id" => 8, "name" => "Desk Combination"},
      %{"id" => 9, "name" => "Customizable Desk"},
      %{"id" => 10, "name" => "Customizable Desk"}
      ],
      model: "product.product",
    opts: [offset: 5, limit: 5, fields: ["name"], order: "id asc"]
  }}

  ```
  """
  def next(odoo = %Odoo.Session{}, result) do
    new_opts = Odoo.Result.next(result.opts)
    Odoo.search_read(odoo, result.model, new_opts)
  end

  @doc """

  Get previous page results (call to api odoo)

  ### Example

  ```elixir
  iex> {:ok, odoo} = Odoo.login(
  ...>    "admin", "admin", "mydatabasename", "https://mydatabasename.odoo.com")
  {:ok,
  %Odoo.Session{
  cookie: "session_id=c8e544d0b305920afgdgsfdfdsa7b0cfe; Expires=Fri, 06-May-2022 23:16:12 GMT; Max-Age=7776000; HttpOnly; Path=/",
  database: "mydatabasename",
  password: "admin",
  url: "https://mydatabasename.odoo.com",
  user: "admin",
  user_context: %{"lang" => "en_US", "tz" => "Asia/Calcutta", "uid" => 2}
  }}
  iex> {:ok, result} = Odoo.search_read(
  ...>      odoo, "product.product", limit: 5, fields: ["name"], order: "id asc")
  {:ok,
  %Odoo.Result{
  data: [
  %{"id" => 1, "name" => "Restaurant Expenses"},
  %{"id" => 2, "name" => "Hotel Accommodation"},
  %{"id" => 3, "name" => "Virtual Interior Design"},
  %{"id" => 4, "name" => "Virtual Home Staging"},
  %{"id" => 5, "name" => "Office Chair"}
  ],
  model: "product.product",
  opts: [limit: 5, fields: ["name"], order: "id asc"]
  }}
  iex> {:ok, result2} = Odoo.next(odoo, result)
  {:ok,
  %Odoo.Result{
  data: [
  %{"id" => 6, "name" => "Office Lamp"},
  %{"id" => 7, "name" => "Office Design Software"},
  %{"id" => 8, "name" => "Desk Combination"},
  %{"id" => 12, "name" => "Customizable Desk"},
  %{"id" => 13, "name" => "Customizable Desk"}
  ],
  model: "product.product",
  opts: [offset: 5, limit: 5, fields: ["name"], order: "id asc"]
  }}
  iex> {:ok, result3} = Odoo.prev(odoo, result2)
  {:ok,
  %Odoo.Result{
  data: [
  %{"id" => 1, "name" => "Restaurant Expenses"},
  %{"id" => 2, "name" => "Hotel Accommodation"},
  %{"id" => 3, "name" => "Virtual Interior Design"},
  %{"id" => 4, "name" => "Virtual Home Staging"},
  %{"id" => 5, "name" => "Office Chair"}
  ],
  model: "product.product",
  opts: [offset: 0, limit: 5, fields: ["name"], order: "id asc"]
  }}
  ```

  """
  def prev(odoo = %Odoo.Session{}, result) do
    new_opts = Odoo.Result.prev(result.opts)
    Odoo.search_read(odoo, result.model, new_opts)
  end

  @doc """

  Execute a method on a model (call to api odoo).

  * Confirm a sale order with id 3
  iex> {:ok, res2} = Odoo.execute(odoo, "sale.order", "action_confirm", [3])
  {:ok, %Odoo.Result{data: true, model: "sale.order", opts: [3]}}

  """
  @spec execute(Odoo.Session.t(), String.t(), String.t(), list(non_neg_integer())) ::
          {:ok, %Odoo.Result{}} | {:error, String.t()}
  def execute(odoo = %Odoo.Session{}, model, method, args) do
    Odoo.Core.execute(odoo, model, method, args)
  end

  defp check_params(user, password, database, url) do
    cond do
      is_nil(user) or String.length(user) == 0 ->
        {:error, "User is required"}

      is_nil(password) || String.length(password) == 0 ->
        {:error, "Password is required"}

      is_nil(database) || String.length(database) == 0 ->
        {:error, "Database is required"}

      is_nil(url) || String.length(url) == 0 ->
        {:error, "Url is required"}

      true ->
        {:ok, "ok"}
    end
  end
end