Refactored and more maintainable payroll code

defmodule Mgp.Sync.ImportPayroll do
  @moduledoc "Import and check payroll data"

  import Mgp.Utils, only: [to_date: 1, to_time: 1]
  alias Mgp.Sync.DbaseParser

  @root_folder "/home/hvaria/backup/HPMG18/"
  @employee_master "H1EMP.DBF"
  @calculated_payroll "H1DETPAY.DBF"

  @decimal_50 Decimal.new("50")

  def import_month(folder \\ @root_folder, month) do
    files = generate_file_paths(folder)

    with employees <- parse_employee_master(files.employee_master),
         payroll <- parse_and_calculate_monthly_payroll(files.payroll, month, employees) do
      Enum.sort_by(payroll, fn x -> x.id end)
    end
  end

  defp generate_file_paths(folder) do
    %{
      payroll: folder <> @calculated_payroll,
      employee_master: folder <> @employee_master
    }
  end

  def parse_employee_master(dbf_file) do
    DbaseParser.parse(
      dbf_file,
      ["EMP_NO", "EMP_NM", "EMP_JOINDT", "EMP_DISCDT", "EMP_RTBASE", "EMP_SSNO", "EMP_FNO1"],
      fn x ->
        %{
          id: x["EMP_NO"],
          name: x["EMP_NM"],
          start_date: parse_date(x["EMP_JOINDT"]),
          end_date: parse_date(x["EMP_DISCDT"]),
          is_terminated: parse_terminated(x["EMP_RTBASE"]),
          ssnit_no: x["EMP_SSNO"],
          tin_no: x["EMP_FNO1"]
        }
      end
    )
    |> Enum.reduce(%{}, fn x, acc ->
      Map.put(acc, x.id, x)
    end)
  end

  defp parse_and_calculate_monthly_payroll(dbf_file, month, employees) do
    tax_year =
      cond do
        month > "1912M" -> 2020
        month > "1812M" -> 2019
        month <= "1812M" -> 2018
      end

    DbaseParser.parse(
      dbf_file,
      [
        "PD_MTH",
        "PD_EMPNO",
        "PD_RATE",
        "PD_DEPT",
        "PD_SDEPT",
        "PD_DESIG",
        "PD_CAT",
        "PD_OTAMT",
        "PD_DAYS",
        "PD_OTHALL",
        "PD_LOAN",
        "PD_ADV",
        "PD_PLADV",
        "PD_SSRT",
        "PD_TUCRT",
        "PD_DEDRT1",
        "PD_DEDRT2",
        "PD_TOTAL",
        "PD_CB",
        "PD_BKDET",
        "PD_LMU",
        "PD_LMD",
        "PD_LMT"
      ],
      fn x ->
        if x["PD_MTH"] === month do
          %{
            month: x["PD_MTH"],
            id: x["PD_EMPNO"],
            base_salary: x["PD_RATE"],
            dept: parse_dept(x["PD_DEPT"]),
            sub_dept: parse_sub_dept(x["PD_SDEPT"]),
            title: x["PD_DESIG"],
            gra_category: x["PD_CAT"],
            overtime_earned: x["PD_OTAMT"],
            days_worked: x["PD_DAYS"],
            cash_allowance: x["PD_OTHALL"],
            loan: x["PD_LOAN"],
            advance: x["PD_ADV"],
            pvt_loan: x["PD_PLADV"],
            ssnit_ded: parse_ssnit_ded(Decimal.to_string(x["PD_SSRT"]), x["PD_EMPNO"]),
            tuc_ded: parse_tuc_ded(Decimal.to_string(x["PD_TUCRT"])),
            staff_welfare_ded: x["PD_DEDRT1"],
            pf_ded: x["PD_DEDRT2"],
            net_pay: x["PD_TOTAL"],
            is_cash: parse_cash(x["PD_CB"]),
            bank: x["PD_BKDET"],
            lmu: x["PD_LMU"],
            lmd: to_date(x["PD_LMD"]),
            lmt: to_time(x["PD_LMT"])
          }
        else
          nil
        end
      end
    )
    |> Task.async_stream(&parse_payroll_record(&1, employees, tax_year))
    |> Stream.map(fn {:ok, res} -> res end)
    # |> Stream.map(&parse_payroll_record(&1))
    |> Enum.to_list()
  end

  ### Private Functions ###
  defp parse_payroll_record(emp, employees, tax_year) do
    earned_salary =
      Decimal.round(Decimal.mult(emp.base_salary, Decimal.div(emp.days_worked, 27)), 2)

    ssnit_amount =
      if emp.id === "E0053" do
        if tax_year < 2020 do
          Decimal.round(Decimal.mult(Decimal.div(emp.ssnit_ded, 100), earned_salary), 2)
        else
          Decimal.new(0)
        end
      else
        Decimal.round(Decimal.mult(Decimal.div(emp.ssnit_ded, 100), earned_salary), 2)
      end

    {ssnit_emp_contrib, ssnit_total, ssnit_tier_1, ssnit_tier_2} =
      case emp.id === "E0053" do
        true ->
          if tax_year < 2020 do
            # 12.5 div 5
            ssnit_emp_contrib =
              Decimal.round(Decimal.mult(Decimal.from_float(2.5), ssnit_amount), 2)

            ssnit_total = Decimal.add(ssnit_amount, ssnit_emp_contrib)
            ssnit_tier_1 = ssnit_total
            ssnit_tier_2 = Decimal.new(0)
            {ssnit_emp_contrib, ssnit_total, ssnit_tier_1, ssnit_tier_2}
          else
            ssnit_emp_contrib = Decimal.new(0)
            ssnit_total = Decimal.new(0)
            ssnit_tier_1 = Decimal.new(0)
            ssnit_tier_2 = Decimal.new(0)
            {ssnit_emp_contrib, ssnit_total, ssnit_tier_1, ssnit_tier_2}
          end

        false ->
          # 13 div 5.5
          ssnit_emp_contrib =
            Decimal.round(Decimal.mult(Decimal.from_float(2.363636364), ssnit_amount), 2)

          ssnit_total = Decimal.add(ssnit_amount, ssnit_emp_contrib)

          ssnit_tier_1 =
            Decimal.round(Decimal.mult(Decimal.from_float(0.72972973), ssnit_total), 2)

          ssnit_tier_2 = Decimal.sub(ssnit_total, ssnit_tier_1)
          {ssnit_emp_contrib, ssnit_total, ssnit_tier_1, ssnit_tier_2}
      end

    pf_amount = Decimal.round(Decimal.mult(Decimal.div(emp.pf_ded, 100), earned_salary), 2)

    total_cash = Decimal.add(earned_salary, emp.cash_allowance)

    total_relief = Decimal.add(ssnit_amount, pf_amount)

    taxable_income = Decimal.sub(total_cash, total_relief)

    tax_ded = gra_income_tax(taxable_income, tax_year)

    overtime_tax = gra_overtime_tax(emp.overtime_earned, earned_salary)

    total_tax = Decimal.add(tax_ded, overtime_tax)

    tuc_amount = Decimal.round(Decimal.mult(Decimal.div(emp.tuc_ded, 100), earned_salary), 2)

    total_ded =
      Decimal.add(
        emp.advance,
        Decimal.add(
          emp.loan,
          Decimal.add(
            emp.pvt_loan,
            Decimal.add(emp.staff_welfare_ded, tuc_amount)
          )
        )
      )

    total_pay = Decimal.sub(taxable_income, Decimal.add(total_tax, total_ded))

    Map.merge(
      emp,
      Map.merge(
        employees[emp.id],
        %{
          earned_salary: earned_salary,
          ssnit_emp_contrib: ssnit_emp_contrib,
          ssnit_amount: ssnit_amount,
          ssnit_total: ssnit_total,
          ssnit_tier_1: ssnit_tier_1,
          ssnit_tier_2: ssnit_tier_2,
          pf_amount: pf_amount,
          taxable_income: taxable_income,
          total_cash: total_cash,
          total_relief: total_relief,
          tax_ded: tax_ded,
          overtime_tax: overtime_tax,
          total_tax: total_tax,
          tuc_amount: tuc_amount,
          total_ded: total_ded,
          total_pay: total_pay
        }
      )
    )
  end

  defp gra_overtime_tax(o, earned_salary) do
    percent = Decimal.mult(Decimal.div(o, earned_salary), 100)

    case Decimal.compare(percent, @decimal_50) do
      :gt ->
        Decimal.round(Decimal.mult(o, Decimal.new("0.1")), 2)

      _ ->
        Decimal.round(Decimal.mult(o, Decimal.new("0.05")), 2)
    end
  end

  defp compute_tax(income, [[tax_bracket, rate, cum_tax] | t], prev_cum_tax) do
    if Decimal.compare(income, tax_bracket) != :gt do
      Decimal.round(
        Decimal.add(
          Decimal.mult(
            Decimal.sub(income, prev_cum_tax),
            rate
          ),
          cum_tax
        ),
        2
      )
    else
      compute_tax(income, t, tax_bracket)
    end
  end

  defp gra_income_tax(taxable_income, 2020) do
    tax_table = [
      # [Chargeable income, % rate, cumulative tax]
      [Decimal.new(319), Decimal.new(0), Decimal.new(0)],
      [Decimal.new(419), Decimal.new("0.050"), Decimal.new(0)],
      [Decimal.new(539), Decimal.new("0.100"), Decimal.new(5)],
      [Decimal.new(3539), Decimal.new("0.175"), Decimal.new(17)],
      [Decimal.new(20000), Decimal.new("0.250"), Decimal.new(542)],
      [%Decimal{coef: :inf}, Decimal.new("0.300"), Decimal.new("4657.25")]
    ]

    compute_tax(taxable_income, tax_table, Decimal.new(0))
  end

  defp gra_income_tax(taxable_income, 2019) do
    tax_table = [
      [Decimal.new(288), Decimal.new(0), Decimal.new(0)],
      [Decimal.new(388), Decimal.new("0.050"), Decimal.new(0)],
      [Decimal.new(528), Decimal.new("0.100"), Decimal.new(5)],
      [Decimal.new(3528), Decimal.new("0.175"), Decimal.new(19)],
      [Decimal.new(20000), Decimal.new("0.250"), Decimal.new(544)],
      [%Decimal{coef: :inf}, Decimal.new("0.300"), Decimal.new(4662)]
    ]

    compute_tax(taxable_income, tax_table, Decimal.new(0))
  end

  defp gra_income_tax(taxable_income, _) do
    tax_table = [
      [Decimal.new(216), Decimal.new(0), Decimal.new(0)],
      [Decimal.new(331), Decimal.new("0.050"), Decimal.new(0)],
      [Decimal.new(431), Decimal.new("0.100"), Decimal.new("3.5")],
      [Decimal.new(3241), Decimal.new("0.175"), Decimal.new("13.5")],
      [Decimal.new(10000), Decimal.new("0.250"), Decimal.new("505.25")],
      [%Decimal{coef: :inf}, Decimal.new("0.350"), Decimal.new(2195)]
    ]

    compute_tax(taxable_income, tax_table, Decimal.new(0))
  end

  defp parse_ssnit_ded(_, "E0053"), do: Decimal.new(5)
  defp parse_ssnit_ded("5.50", _), do: Decimal.new("5.5")
  defp parse_ssnit_ded("0.00", _), do: Decimal.new(0)
  defp parse_ssnit_ded("N", _), do: Decimal.new(0)
  defp parse_ssnit_ded("Y", "E0053"), do: Decimal.new(5)
  defp parse_ssnit_ded("Y", _), do: Decimal.new("5.5")

  defp parse_dept("M001"), do: "ADMINSTRATION"
  defp parse_dept("M002"), do: "FACTORY"
  defp parse_dept(_), do: ""

  defp parse_sub_dept("S001"), do: "DRIVERS"
  defp parse_sub_dept("S002"), do: "MARKETING"
  defp parse_sub_dept("S003"), do: "SECURITY"
  defp parse_sub_dept("S004"), do: "STORES"
  defp parse_sub_dept("S005"), do: "WORKERS"
  defp parse_sub_dept("S006"), do: "QUALITY CONTROL"
  defp parse_sub_dept("S007"), do: "MAINTENANCE"
  defp parse_sub_dept("S008"), do: "OTHERS"
  defp parse_sub_dept(_), do: ""

  defp parse_terminated("M"), do: false
  defp parse_terminated("X"), do: true

  defp parse_cash("C"), do: true
  defp parse_cash("B"), do: false

  defp parse_tuc_ded("2.00"), do: Decimal.new(2)
  defp parse_tuc_ded("0.00"), do: Decimal.new(0)
  defp parse_tuc_ded("Y"), do: Decimal.new(2)
  defp parse_tuc_ded("N"), do: Decimal.new(0)

  defp parse_date(""), do: nil
  defp parse_date(date), do: to_date(date)
end

defmodule Mgp.Sync.ImportPayroll do
  @moduledoc "Import and check payroll data"

  import Mgp.Utils, only: [to_date: 1, to_time: 1]
  alias Mgp.Sync.DbaseParser

  @root_folder "/home/hvaria/backup/HPMG18/"
  @employee_master "H1EMP.DBF"
  @calculated_payroll "H1DETPAY.DBF"

  @decimal_50 Decimal.new("50")

  def import_month(folder \\ @root_folder, month) do
    files = generate_file_paths(folder)

    with employees <- parse_employee_master(files.employee_master),
         payroll <- parse_and_calculate_monthly_payroll(files.payroll, month, employees) do
      Enum.sort_by(payroll, fn x -> x.id end)
    end
  end

  defp generate_file_paths(folder) do
    %{
      payroll: folder <> @calculated_payroll,
      employee_master: folder <> @employee_master
    }
  end

  def parse_employee_master(dbf_file) do
    DbaseParser.parse(
      dbf_file,
      ["EMP_NO", "EMP_NM", "EMP_JOINDT", "EMP_DISCDT", "EMP_RTBASE", "EMP_SSNO", "EMP_FNO1"],
      fn x ->
        %{
          id: x["EMP_NO"],
          name: x["EMP_NM"],
          start_date: parse_date(x["EMP_JOINDT"]),
          end_date: parse_date(x["EMP_DISCDT"]),
          is_terminated: parse_terminated(x["EMP_RTBASE"]),
          ssnit_no: x["EMP_SSNO"],
          tin_no: x["EMP_FNO1"]
        }
      end
    )
    |> Enum.reduce(%{}, fn x, acc ->
      Map.put(acc, x.id, x)
    end)
  end

  defp parse_and_calculate_monthly_payroll(dbf_file, month, employees) do
    tax_year =
      cond do
        month > "1912M" -> 2020
        month > "1812M" -> 2019
        month <= "1812M" -> 2018
      end

    DbaseParser.parse(
      dbf_file,
      [
        "PD_MTH",
        "PD_EMPNO",
        "PD_RATE",
        "PD_DEPT",
        "PD_SDEPT",
        "PD_DESIG",
        "PD_CAT",
        "PD_OTAMT",
        "PD_DAYS",
        "PD_OTHALL",
        "PD_LOAN",
        "PD_ADV",
        "PD_PLADV",
        "PD_SSRT",
        "PD_TUCRT",
        "PD_DEDRT1",
        "PD_DEDRT2",
        "PD_TOTAL",
        "PD_CB",
        "PD_BKDET",
        "PD_LMU",
        "PD_LMD",
        "PD_LMT"
      ],
      fn x ->
        if x["PD_MTH"] === month do
          %{
            month: x["PD_MTH"],
            id: x["PD_EMPNO"],
            base_salary: x["PD_RATE"],
            dept: parse_dept(x["PD_DEPT"]),
            sub_dept: parse_sub_dept(x["PD_SDEPT"]),
            title: x["PD_DESIG"],
            gra_category: x["PD_CAT"],
            overtime_earned: x["PD_OTAMT"],
            days_worked: x["PD_DAYS"],
            cash_allowance: x["PD_OTHALL"],
            loan: x["PD_LOAN"],
            advance: x["PD_ADV"],
            pvt_loan: x["PD_PLADV"],
            ssnit_ded: parse_ssnit_ded(Decimal.to_string(x["PD_SSRT"]), x["PD_EMPNO"]),
            tuc_ded: parse_tuc_ded(Decimal.to_string(x["PD_TUCRT"])),
            staff_welfare_ded: x["PD_DEDRT1"],
            pf_ded: x["PD_DEDRT2"],
            net_pay: x["PD_TOTAL"],
            is_cash: parse_cash(x["PD_CB"]),
            bank: x["PD_BKDET"],
            lmu: x["PD_LMU"],
            lmd: to_date(x["PD_LMD"]),
            lmt: to_time(x["PD_LMT"])
          }
        else
          nil
        end
      end
    )
    |> Task.async_stream(&parse_payroll_record(&1, employees, tax_year))
    |> Stream.map(fn {:ok, res} -> res end)
    # |> Stream.map(&parse_payroll_record(&1))
    |> Enum.to_list()
  end

  ### Private Functions ###
  defp parse_payroll_record(emp, employees, tax_year) do
    earned_salary =
      Decimal.round(Decimal.mult(emp.base_salary, Decimal.div(emp.days_worked, 27)), 2)

    ssnit_amount =
      if emp.id === "E0053" do
        if tax_year < 2020 do
          Decimal.round(Decimal.mult(Decimal.div(emp.ssnit_ded, 100), earned_salary), 2)
        else
          Decimal.new(0)
        end
      else
        Decimal.round(Decimal.mult(Decimal.div(emp.ssnit_ded, 100), earned_salary), 2)
      end

    {ssnit_emp_contrib, ssnit_total, ssnit_tier_1, ssnit_tier_2} =
      case emp.id === "E0053" do
        true ->
          if tax_year < 2020 do
            # 12.5 div 5
            ssnit_emp_contrib =
              Decimal.round(Decimal.mult(Decimal.from_float(2.5), ssnit_amount), 2)

            ssnit_total = Decimal.add(ssnit_amount, ssnit_emp_contrib)
            ssnit_tier_1 = ssnit_total
            ssnit_tier_2 = Decimal.new(0)
            {ssnit_emp_contrib, ssnit_total, ssnit_tier_1, ssnit_tier_2}
          else
            ssnit_emp_contrib = Decimal.new(0)
            ssnit_total = Decimal.new(0)
            ssnit_tier_1 = Decimal.new(0)
            ssnit_tier_2 = Decimal.new(0)
            {ssnit_emp_contrib, ssnit_total, ssnit_tier_1, ssnit_tier_2}
          end

        false ->
          # 13 div 5.5
          ssnit_emp_contrib =
            Decimal.round(Decimal.mult(Decimal.from_float(2.363636364), ssnit_amount), 2)

          ssnit_total = Decimal.add(ssnit_amount, ssnit_emp_contrib)

          ssnit_tier_1 =
            Decimal.round(Decimal.mult(Decimal.from_float(0.72972973), ssnit_total), 2)

          ssnit_tier_2 = Decimal.sub(ssnit_total, ssnit_tier_1)
          {ssnit_emp_contrib, ssnit_total, ssnit_tier_1, ssnit_tier_2}
      end

    pf_amount = Decimal.round(Decimal.mult(Decimal.div(emp.pf_ded, 100), earned_salary), 2)

    total_cash = Decimal.add(earned_salary, emp.cash_allowance)

    total_relief = Decimal.add(ssnit_amount, pf_amount)

    taxable_income = Decimal.sub(total_cash, total_relief)

    tax_ded = gra_income_tax(taxable_income, tax_year)

    overtime_tax = gra_overtime_tax(emp.overtime_earned, earned_salary)

    total_tax = Decimal.add(tax_ded, overtime_tax)

    tuc_amount = Decimal.round(Decimal.mult(Decimal.div(emp.tuc_ded, 100), earned_salary), 2)

    total_ded =
      Decimal.add(
        emp.advance,
        Decimal.add(
          emp.loan,
          Decimal.add(
            emp.pvt_loan,
            Decimal.add(emp.staff_welfare_ded, tuc_amount)
          )
        )
      )

    total_pay = Decimal.sub(taxable_income, Decimal.add(total_tax, total_ded))

    Map.merge(
      emp,
      Map.merge(
        employees[emp.id],
        %{
          earned_salary: earned_salary,
          ssnit_emp_contrib: ssnit_emp_contrib,
          ssnit_amount: ssnit_amount,
          ssnit_total: ssnit_total,
          ssnit_tier_1: ssnit_tier_1,
          ssnit_tier_2: ssnit_tier_2,
          pf_amount: pf_amount,
          taxable_income: taxable_income,
          total_cash: total_cash,
          total_relief: total_relief,
          tax_ded: tax_ded,
          overtime_tax: overtime_tax,
          total_tax: total_tax,
          tuc_amount: tuc_amount,
          total_ded: total_ded,
          total_pay: total_pay
        }
      )
    )
  end

  defp gra_overtime_tax(o, earned_salary) do
    percent = Decimal.mult(Decimal.div(o, earned_salary), 100)

    case Decimal.compare(percent, @decimal_50) do
      :gt ->
        Decimal.round(Decimal.mult(o, Decimal.new("0.1")), 2)

      _ ->
        Decimal.round(Decimal.mult(o, Decimal.new("0.05")), 2)
    end
  end

  defp gra_income_tax(i, 2020) do
    cond do
      Decimal.compare(i, Decimal.new("319")) != :gt ->
        Decimal.new("0")

      Decimal.compare(i, Decimal.new("419")) != :gt ->
        Decimal.round(Decimal.mult(Decimal.sub(i, 319), Decimal.new("0.05")), 2)

      Decimal.compare(i, Decimal.new("539")) != :gt ->
        Decimal.round(
          Decimal.add(Decimal.mult(Decimal.sub(i, 419), Decimal.new("0.1")), Decimal.new("5")),
          2
        )

      Decimal.compare(i, Decimal.new("3539")) != :gt ->
        Decimal.round(
          Decimal.add(
            Decimal.mult(Decimal.sub(i, 539), Decimal.new("0.175")),
            Decimal.new("17")
          ),
          2
        )

      Decimal.compare(i, Decimal.new("20000")) != :gt ->
        Decimal.round(
          Decimal.add(
            Decimal.mult(Decimal.sub(i, 3539), Decimal.new("0.25")),
            Decimal.new("542")
          ),
          2
        )

      true ->
        Decimal.round(
          Decimal.add(
            Decimal.mult(Decimal.sub(i, 20000), Decimal.new("0.3")),
            Decimal.new("4657.25")
          ),
          2
        )
    end
  end

  defp gra_income_tax(i, 2019) do
    cond do
      Decimal.compare(i, Decimal.new("288")) != :gt ->
        Decimal.new("0")

      Decimal.compare(i, Decimal.new("388")) != :gt ->
        Decimal.round(Decimal.mult(Decimal.sub(i, 288), Decimal.new("0.05")), 2)

      Decimal.compare(i, Decimal.new("528")) != :gt ->
        Decimal.round(
          Decimal.add(Decimal.mult(Decimal.sub(i, 388), Decimal.new("0.1")), Decimal.new("5")),
          2
        )

      Decimal.compare(i, Decimal.new("3528")) != :gt ->
        Decimal.round(
          Decimal.add(
            Decimal.mult(Decimal.sub(i, 528), Decimal.new("0.175")),
            Decimal.new("19")
          ),
          2
        )

      Decimal.compare(i, Decimal.new("20000")) != :gt ->
        Decimal.round(
          Decimal.add(
            Decimal.mult(Decimal.sub(i, 3528), Decimal.new("0.25")),
            Decimal.new("544")
          ),
          2
        )

      true ->
        Decimal.round(
          Decimal.add(
            Decimal.mult(Decimal.sub(i, 20000), Decimal.new("0.3")),
            Decimal.new("4662")
          ),
          2
        )
    end
  end

  defp gra_income_tax(i, _) do
    cond do
      Decimal.compare(i, Decimal.new("216")) != :gt ->
        Decimal.new("0")

      Decimal.compare(i, Decimal.new("331")) != :gt ->
        Decimal.round(Decimal.mult(Decimal.sub(i, 216), Decimal.new("0.05")), 2)

      Decimal.compare(i, Decimal.new("431")) != :gt ->
        Decimal.round(
          Decimal.add(Decimal.mult(Decimal.sub(i, 331), Decimal.new("0.1")), Decimal.new("3.5")),
          2
        )

      Decimal.compare(i, Decimal.new("3241")) != :gt ->
        Decimal.round(
          Decimal.add(
            Decimal.mult(Decimal.sub(i, 431), Decimal.new("0.175")),
            Decimal.new("13.5")
          ),
          2
        )

      Decimal.compare(i, Decimal.new("10000")) != :gt ->
        Decimal.round(
          Decimal.add(
            Decimal.mult(Decimal.sub(i, 3241), Decimal.new("0.25")),
            Decimal.new("505.25")
          ),
          2
        )

      true ->
        Decimal.round(
          Decimal.add(
            Decimal.mult(Decimal.sub(i, 10000), Decimal.new("0.35")),
            Decimal.new("2195")
          ),
          2
        )
    end
  end

  defp parse_ssnit_ded(_, "E0053"), do: Decimal.new(5)
  defp parse_ssnit_ded("5.50", _), do: Decimal.new("5.5")
  defp parse_ssnit_ded("0.00", _), do: Decimal.new(0)
  defp parse_ssnit_ded("N", _), do: Decimal.new(0)
  defp parse_ssnit_ded("Y", "E0053"), do: Decimal.new(5)
  defp parse_ssnit_ded("Y", _), do: Decimal.new("5.5")

  defp parse_dept("M001"), do: "ADMINSTRATION"
  defp parse_dept("M002"), do: "FACTORY"
  defp parse_dept(_), do: ""

  defp parse_sub_dept("S001"), do: "DRIVERS"
  defp parse_sub_dept("S002"), do: "MARKETING"
  defp parse_sub_dept("S003"), do: "SECURITY"
  defp parse_sub_dept("S004"), do: "STORES"
  defp parse_sub_dept("S005"), do: "WORKERS"
  defp parse_sub_dept("S006"), do: "QUALITY CONTROL"
  defp parse_sub_dept("S007"), do: "MAINTENANCE"
  defp parse_sub_dept("S008"), do: "OTHERS"
  defp parse_sub_dept(_), do: ""

  defp parse_terminated("M"), do: false
  defp parse_terminated("X"), do: true

  defp parse_cash("C"), do: true
  defp parse_cash("B"), do: false

  defp parse_tuc_ded("2.00"), do: Decimal.new(2)
  defp parse_tuc_ded("0.00"), do: Decimal.new(0)
  defp parse_tuc_ded("Y"), do: Decimal.new(2)
  defp parse_tuc_ded("N"), do: Decimal.new(0)

  defp parse_date(""), do: nil
  defp parse_date(date), do: to_date(date)
end


Give fixed height to jury sheet header

Give fixed height to jury sheet header

defmodule JumubaseWeb.Generators.PDFGenerator do
  import Jumubase.Gettext
  import JumubaseWeb.DateHelpers
  import JumubaseWeb.Internal.AppearanceView, only: [acc: 1, instrument_name: 1, non_acc: 1]
  import JumubaseWeb.Internal.ParticipantView, only: [full_name: 1]
  import JumubaseWeb.Internal.PerformanceView, only: [category_name: 1, category_info: 1]
  import JumubaseWeb.Internal.PieceView, only: [duration: 1, person_info: 1]
  alias Jumubase.Showtime.{Appearance, Performance, Piece}
  alias Jumubase.Showtime.Results

  @border_style "1px solid black"
  @muted_color "#666"

  @doc """
  Returns PDF jury sheets (one per page) for the given performances.
  """
  def jury_sheets(performances, round) do
    render_performance_pages(performances, round) |> generate_pdf("portrait")
  end

  @doc """
  Returns a PDF jury table (used for collecting points) for all given performances.
  """
  def jury_table(performances) do
    render_performance_table(performances) |> generate_pdf("landscape")
  end

  # Private helpers

  defp generate_pdf(body_html, orientation) do
    html = Sneeze.render([:html,
      [:head, [:meta, %{charset: "UTF-8"}]],
      [:body,
        %{style: style(%{
          "font-family" => "LatoLatin",
          "font-size" => "18px",
          "line-height" => 1.3,
        })},
        body_html
      ]
    ])

    base_params = ["--disable-smart-shrinking", "--orientation", orientation, "--quiet"]

    # Adjust zoom level to account for different DPI, using Mac (96 dpi) as baseline
    shell_params = case :os.type do
      {:unix, :linux} ->
        base_params ++ ["--zoom", "0.78125"] # 75 dpi / 96 dpi
      _ ->
        base_params
    end

    PdfGenerator.generate_binary!(html,
      page_size: "A4",
      shell_params: shell_params
    )
  end

  defp render_performance_pages(performances, round) do
    for p <- performances do
      [:div, %{style: style(%{"page-break-before" => "always"})},
        [:div, %{style: style(%{"border-bottom" => @border_style, "height" => "55px"})},
          [:p, [
            [:span, category_info(p)],
            [:span, format_datetime(p.stage_time)]
          ] |> to_lines]
        ],
        [:div, %{style: style(%{"margin-top" => "50px", "height" => "300px"})}, render_appearances(p)],
        [:div, %{style: style(%{"height" => "580px"})}, render_pieces(p)],
        [:div, %{style: style(%{"height" => "50px"})}, render_point_ranges(round)],
      ]
    end
  end

  defp render_appearances(%Performance{appearances: appearances, age_group: ag}) do
    non_acc_div = [:div, non_acc(appearances) |> to_appearance_lines(ag)]
    if (acc = acc(appearances)) != [] do
      acc_div = [:div, acc |> to_appearance_lines(ag)]
      [non_acc_div, acc_heading(), acc_div]
    else
      non_acc_div
    end
  end

  defp to_appearance_lines(appearances, performance_ag) do
    appearances
    |> Enum.map(fn a ->
      ag_info = age_group_info(a, performance_ag)
      [
        [:b, full_name(a.participant)],
        [:span, ", #{instrument_name(a.instrument)} #{ag_info}"]
      ]
    end)
    |> to_lines
  end

  defp acc_heading do
    [:div, %{style: style(%{"color" => @muted_color, "line-height" => 2})},
      gettext("accompanied by")
    ]
  end

  defp render_pieces(%Performance{pieces: pieces}) do
    pieces |> Enum.map(&render_piece/1)
  end

  defp render_piece(%Piece{} = pc) do
    [:div, %{style: style(%{"margin-bottom" => "20px"})}, [
      [:b, person_info(pc)],
      [:span, pc.title],
      [:span, %{style: style(%{"color" => @muted_color})},
        "#{duration(pc)} / #{gettext("Epoch")} #{pc.epoch}"
      ],
    ] |> to_lines]
  end

  defp render_point_ranges(round) do
    {left_side, right_side} =
      Results.prizes_for_round(round)
      |> Map.merge(Results.ratings_for_round(round))
      |> Enum.reverse
      |> Enum.split(3)

    [:div, %{style: style(%{"color" => @muted_color, "font-size" => "12px"})},
      format_point_ranges(left_side),
      format_point_ranges(right_side)
    ]
  end

  defp format_point_ranges(point_ranges) do
    [:div, %{style: style(%{"display" => "inline-block", "vertical-align" => "top", "width" => "50%"})},
      point_ranges |> Enum.map(&format_point_range/1) |> to_lines
    ]
  end

  defp format_point_range({first..last, text}) do
    [:span, "#{first}–#{last} #{gettext("points")}: #{text}"]
  end

  defp to_lines(items), do: Enum.intersperse(items, [:br])

  defp render_performance_table(performances) do
    table = [:table,
      %{style: style(%{
        "border" => "1px solid black",
        "border-collapse" => "collapse",
        "width" => "100%"})
      },
      [:tr,
        [:th, %{style: cell_style(%{"width" => "auto"})}, gettext("Category")],
        [:th, %{style: cell_style(%{"width" => "1%", "white-space" => "nowrap"})}, gettext("AG")],
        [:th, %{style: cell_style(%{"width" => "auto"})}, gettext("Participants")],
        [:th, %{style: cell_style(%{"width" => "5%"})}, "J1"],
        [:th, %{style: cell_style(%{"width" => "5%"})}, "J2"],
        [:th, %{style: cell_style(%{"width" => "5%"})}, "J3"],
        [:th, %{style: cell_style(%{"width" => "5%"})}, "J4"],
        [:th, %{style: cell_style(%{"width" => "5%"})}, "J5"],
        [:th, %{style: cell_style(%{"width" => "10%"})}, gettext("Result")],
      ]
    ]
    performance_rows = Enum.map(performances, &render_performance_row/1)
    table ++ performance_rows
  end

  defp render_performance_row(%Performance{} = p) do
    [:tr,
      [:td, %{style: cell_style()}, category_name(p)],
      [:td, %{style: cell_style()}, p.age_group],
      [:td, %{style: cell_style()}, render_list_appearances(p)],
      [:td, %{style: cell_style()}],
      [:td, %{style: cell_style()}],
      [:td, %{style: cell_style()}],
      [:td, %{style: cell_style()}],
      [:td, %{style: cell_style()}],
      [:td, %{style: cell_style()}],
    ]
  end

  defp render_list_appearances(%Performance{appearances: appearances, age_group: p_ag}) do
    non_acc = non_acc(appearances) |> Enum.map(&render_list_appearance(&1, p_ag)) |> to_lines
    acc = acc(appearances) |> Enum.map(&render_list_appearance(&1, p_ag)) |> to_lines

    if acc != [], do: non_acc ++ [acc_heading()] ++ acc, else: non_acc
  end

  defp render_list_appearance(%Appearance{} = a, performance_ag) do
    ag_info = age_group_info(a, performance_ag)
    [:span, "#{full_name(a.participant)}, #{instrument_name(a.instrument)} #{ag_info}"]
  end

  defp age_group_info(%Appearance{age_group: ag}, performance_ag) do
    if ag != performance_ag, do: "(AG #{ag})", else: nil
  end

  defp cell_style(style_map \\ %{}) do
    %{
      "border" => @border_style,
      "padding" => "10px",
      "text-align" => "left",
      "vertical-align" => "top"
    }
    |> Map.merge(style_map)
    |> style
  end

  defp style(style_map) do
    style_map
    |> Enum.map(fn {key, value} -> "#{key}: #{value}" end)
    |> Enum.join(";")
  end
end

defmodule JumubaseWeb.Generators.PDFGenerator do
  import Jumubase.Gettext
  import JumubaseWeb.DateHelpers
  import JumubaseWeb.Internal.AppearanceView, only: [acc: 1, instrument_name: 1, non_acc: 1]
  import JumubaseWeb.Internal.ParticipantView, only: [full_name: 1]
  import JumubaseWeb.Internal.PerformanceView, only: [category_name: 1, category_info: 1]
  import JumubaseWeb.Internal.PieceView, only: [duration: 1, person_info: 1]
  alias Jumubase.Showtime.{Appearance, Performance, Piece}
  alias Jumubase.Showtime.Results

  @border_style "1px solid black"
  @muted_color "#666"

  @doc """
  Returns PDF jury sheets (one per page) for the given performances.
  """
  def jury_sheets(performances, round) do
    render_performance_pages(performances, round) |> generate_pdf("portrait")
  end

  @doc """
  Returns a PDF jury table (used for collecting points) for all given performances.
  """
  def jury_table(performances) do
    render_performance_table(performances) |> generate_pdf("landscape")
  end

  # Private helpers

  defp generate_pdf(body_html, orientation) do
    html = Sneeze.render([:html,
      [:head, [:meta, %{charset: "UTF-8"}]],
      [:body,
        %{style: style(%{
          "font-family" => "LatoLatin",
          "font-size" => "18px",
          "line-height" => 1.3,
        })},
        body_html
      ]
    ])

    base_params = ["--disable-smart-shrinking", "--orientation", orientation, "--quiet"]

    # Adjust zoom level to account for different DPI, using Mac (96 dpi) as baseline
    shell_params = case :os.type do
      {:unix, :linux} ->
        base_params ++ ["--zoom", "0.78125"] # 75 dpi / 96 dpi
      _ ->
        base_params
    end

    PdfGenerator.generate_binary!(html,
      page_size: "A4",
      shell_params: shell_params
    )
  end

  defp render_performance_pages(performances, round) do
    for p <- performances do
      [:div, %{style: style(%{"page-break-before" => "always"})},
        [:div, %{style: style(%{"border-bottom" => @border_style})},
          [:p, [
            [:span, category_info(p)],
            [:span, format_datetime(p.stage_time)]
          ] |> to_lines]
        ],
        [:div, %{style: style(%{"margin-top" => "50px", "height" => "300px"})}, render_appearances(p)],
        [:div, %{style: style(%{"height" => "580px"})}, render_pieces(p)],
        [:div, %{style: style(%{"height" => "50px"})}, render_point_ranges(round)],
      ]
    end
  end

  defp render_appearances(%Performance{appearances: appearances, age_group: ag}) do
    non_acc_div = [:div, non_acc(appearances) |> to_appearance_lines(ag)]
    if (acc = acc(appearances)) != [] do
      acc_div = [:div, acc |> to_appearance_lines(ag)]
      [non_acc_div, acc_heading(), acc_div]
    else
      non_acc_div
    end
  end

  defp to_appearance_lines(appearances, performance_ag) do
    appearances
    |> Enum.map(fn a ->
      ag_info = age_group_info(a, performance_ag)
      [
        [:b, full_name(a.participant)],
        [:span, ", #{instrument_name(a.instrument)} #{ag_info}"]
      ]
    end)
    |> to_lines
  end

  defp acc_heading do
    [:div, %{style: style(%{"color" => @muted_color, "line-height" => 2})},
      gettext("accompanied by")
    ]
  end

  defp render_pieces(%Performance{pieces: pieces}) do
    pieces |> Enum.map(&render_piece/1)
  end

  defp render_piece(%Piece{} = pc) do
    [:div, %{style: style(%{"margin-bottom" => "20px"})}, [
      [:b, person_info(pc)],
      [:span, pc.title],
      [:span, %{style: style(%{"color" => @muted_color})},
        "#{duration(pc)} / #{gettext("Epoch")} #{pc.epoch}"
      ],
    ] |> to_lines]
  end

  defp render_point_ranges(round) do
    {left_side, right_side} =
      Results.prizes_for_round(round)
      |> Map.merge(Results.ratings_for_round(round))
      |> Enum.reverse
      |> Enum.split(3)

    [:div, %{style: style(%{"color" => @muted_color, "font-size" => "12px"})},
      format_point_ranges(left_side),
      format_point_ranges(right_side)
    ]
  end

  defp format_point_ranges(point_ranges) do
    [:div, %{style: style(%{"display" => "inline-block", "vertical-align" => "top", "width" => "50%"})},
      point_ranges |> Enum.map(&format_point_range/1) |> to_lines
    ]
  end

  defp format_point_range({first..last, text}) do
    [:span, "#{first}–#{last} #{gettext("points")}: #{text}"]
  end

  defp to_lines(items), do: Enum.intersperse(items, [:br])

  defp render_performance_table(performances) do
    table = [:table,
      %{style: style(%{
        "border" => "1px solid black",
        "border-collapse" => "collapse",
        "width" => "100%"})
      },
      [:tr,
        [:th, %{style: cell_style(%{"width" => "auto"})}, gettext("Category")],
        [:th, %{style: cell_style(%{"width" => "1%", "white-space" => "nowrap"})}, gettext("AG")],
        [:th, %{style: cell_style(%{"width" => "auto"})}, gettext("Participants")],
        [:th, %{style: cell_style(%{"width" => "5%"})}, "J1"],
        [:th, %{style: cell_style(%{"width" => "5%"})}, "J2"],
        [:th, %{style: cell_style(%{"width" => "5%"})}, "J3"],
        [:th, %{style: cell_style(%{"width" => "5%"})}, "J4"],
        [:th, %{style: cell_style(%{"width" => "5%"})}, "J5"],
        [:th, %{style: cell_style(%{"width" => "10%"})}, gettext("Result")],
      ]
    ]
    performance_rows = Enum.map(performances, &render_performance_row/1)
    table ++ performance_rows
  end

  defp render_performance_row(%Performance{} = p) do
    [:tr,
      [:td, %{style: cell_style()}, category_name(p)],
      [:td, %{style: cell_style()}, p.age_group],
      [:td, %{style: cell_style()}, render_list_appearances(p)],
      [:td, %{style: cell_style()}],
      [:td, %{style: cell_style()}],
      [:td, %{style: cell_style()}],
      [:td, %{style: cell_style()}],
      [:td, %{style: cell_style()}],
      [:td, %{style: cell_style()}],
    ]
  end

  defp render_list_appearances(%Performance{appearances: appearances, age_group: p_ag}) do
    non_acc = non_acc(appearances) |> Enum.map(&render_list_appearance(&1, p_ag)) |> to_lines
    acc = acc(appearances) |> Enum.map(&render_list_appearance(&1, p_ag)) |> to_lines

    if acc != [], do: non_acc ++ [acc_heading()] ++ acc, else: non_acc
  end

  defp render_list_appearance(%Appearance{} = a, performance_ag) do
    ag_info = age_group_info(a, performance_ag)
    [:span, "#{full_name(a.participant)}, #{instrument_name(a.instrument)} #{ag_info}"]
  end

  defp age_group_info(%Appearance{age_group: ag}, performance_ag) do
    if ag != performance_ag, do: "(AG #{ag})", else: nil
  end

  defp cell_style(style_map \\ %{}) do
    %{
      "border" => @border_style,
      "padding" => "10px",
      "text-align" => "left",
      "vertical-align" => "top"
    }
    |> Map.merge(style_map)
    |> style
  end

  defp style(style_map) do
    style_map
    |> Enum.map(fn {key, value} -> "#{key}: #{value}" end)
    |> Enum.join(";")
  end
end


Updated USQ schema to latest ExoSQL

Updated USQ schema to latest ExoSQL

require Logger

defmodule Serverboards.Query do
  @moduledoc ~S"""
  Universal Service Query.

  This module allows to use the services extractors to extract data and
  perform SQL querys.

  This is exported to the users at:
    * `dashboards.widgets.extract widget_id, extra_params`
    * `query.query sql, extra_params, context_data`

  Also available:
   * `query.schema [table]`

  context_data is the dictionary of services and configs. extra_params are
  extra parameters that are allowed to change in the `dashboards.widgets.extract`
  that does not require special permissions to access all data.
  """

  def execute(config, table, quals, columns) do
    extractor = config.extractor

    Logger.debug("Use extractor #{inspect extractor}")
    [component] = Serverboards.Plugin.Registry.filter_component(id: extractor)

    case Serverboards.Plugin.Runner.start_call_stop(component.extra["command"], component.extra["extractor"], [config, table, quals, columns], config.user) do
      {:ok, result} ->
        {:ok, %{ columns: result["columns"], rows: result["rows"] }}
      other -> other
    end
  end

  @doc ~S"""
  Returns the list of tables on this extractor
  """
  def schema(config) do
    Logger.debug("schema #{inspect config}")
    extractor = config.extractor
    case Serverboards.Plugin.Registry.filter_component(id: extractor) do
      [component] ->
        Serverboards.Plugin.Runner.start_call_stop(component.extra["command"], component.extra["schema"], [config, nil], config.user)
      _ ->
        {:error, :unknown_extractor}
    end

  end

  @doc ~S"""
  Returns the schema of the given table.
  """
  def schema(config, table) do
    extractor = config.extractor
    case Serverboards.Plugin.Registry.filter_component(id: extractor) do
      [component] ->
        res = Serverboards.Plugin.Runner.start_call_stop(component.extra["command"], component.extra["schema"], [config, table], config.user)
        case res do
          {:ok, %{ "columns" => columns}} ->
            columns = Enum.map(columns, fn
              %{ "name" => name } -> name
              other when is_binary(other) -> other
            end)
            {:ok, %{ columns: columns }}

          other -> other
        end
      _ ->
        {:error, {:unknown_extractor, extractor}}
    end

  end

  def query(query, context) do
    context = Enum.map(context, fn {k,v} ->
      nv = {Serverboards.Query, v}
      {k, nv}
    end) |> Map.new

    # If not starts with SELECT, it is a simple text to use
    if not String.starts_with?(String.upcase(query), "SELECT") do
      {:ok, %{ columns: "?VALUE?", rows: [[query]]} }
    else
      # Logger.debug("Processed context #{inspect context}")

      try do
        with {:ok, %{ columns: columns, rows: rows}} <- ExoSQL.query(query, context) do
          {:ok, %{ columns: columns, rows: rows}}
        end
      catch
        any ->
          {:error, any}
      rescue
        e in MatchError ->
          Logger.error(inspect e)
          {:error, :invalid_sql}
        e in FunctionClauseError ->
          Logger.error(inspect e)
          {:error, :invalid_expression}
        exception ->
          Logger.error("#{inspect exception}: #{inspect System.stacktrace, pretty: true}")
          {:error, "Meditation code: `#{inspect exception}`. Ask for help at the forums."}
      end
    end
  end
end

require Logger

defmodule Serverboards.Query do
  @moduledoc ~S"""
  Universal Service Query.

  This module allows to use the services extractors to extract data and
  perform SQL querys.

  This is exported to the users at:
    * `dashboards.widgets.extract widget_id, extra_params`
    * `query.query sql, extra_params, context_data`

  Also available:
   * `query.schema [table]`

  context_data is the dictionary of services and configs. extra_params are
  extra parameters that are allowed to change in the `dashboards.widgets.extract`
  that does not require special permissions to access all data.
  """

  def execute(config, table, quals, columns) do
    extractor = config.extractor

    Logger.debug("Use extractor #{inspect extractor}")
    [component] = Serverboards.Plugin.Registry.filter_component(id: extractor)

    case Serverboards.Plugin.Runner.start_call_stop(component.extra["command"], component.extra["extractor"], [config, table, quals, columns], config.user) do
      {:ok, result} ->
        {:ok, %{ headers: result["columns"], rows: result["rows"] }}
      other -> other
    end
  end

  @doc ~S"""
  Returns the list of tables on this extractor
  """
  def schema(config) do
    Logger.debug("schema #{inspect config}")
    extractor = config.extractor
    case Serverboards.Plugin.Registry.filter_component(id: extractor) do
      [component] ->
        Serverboards.Plugin.Runner.start_call_stop(component.extra["command"], component.extra["schema"], [config, nil], config.user)
      _ ->
        {:error, :unknown_extractor}
    end

  end

  @doc ~S"""
  Returns the schema of the given table.
  """
  def schema(config, table) do
    extractor = config.extractor
    case Serverboards.Plugin.Registry.filter_component(id: extractor) do
      [component] ->
        res = Serverboards.Plugin.Runner.start_call_stop(component.extra["command"], component.extra["schema"], [config, table], config.user)
        case res do
          {:ok, %{ "columns" => columns}} ->
            columns = Enum.map(columns, fn
              %{ "name" => name } -> name
              other when is_binary(other) -> other
            end)
            {:ok, %{ headers: columns }}

          other -> other
        end
      _ ->
        {:error, {:unknown_extractor, extractor}}
    end

  end

  def query(query, context) do
    context = Enum.map(context, fn {k,v} ->
      nv = {Serverboards.Query, v}
      {k, nv}
    end) |> Map.new

    # If not starts with SELECT, it is a simple text to use
    if not String.starts_with?(String.upcase(query), "SELECT") do
      {:ok, %{ columns: "?VALUE?", rows: [[query]]} }
    else
      # Logger.debug("Processed context #{inspect context}")

      try do
        with {:ok, %{ columns: columns, rows: rows}} <- ExoSQL.query(query, context) do
          {:ok, %{ columns: columns, rows: rows}}
        end
      catch
        any ->
          {:error, any}
      rescue
        e in MatchError ->
          Logger.error(inspect e)
          {:error, :invalid_sql}
        e in FunctionClauseError ->
          Logger.error(inspect e)
          {:error, :invalid_expression}
        any ->
          Logger.error(inspect any)
          {:error, "Meditation code: `#{inspect any}`. Ask for help at the forums."}
      end
    end
  end
end


Don't display archival banner for non-archivable anonymous asciicasts

defmodule AsciinemaWeb.AsciicastController do
  use AsciinemaWeb, :controller
  alias Asciinema.{Asciicasts, PngGenerator, Accounts}
  alias Asciinema.Asciicasts.Asciicast

  plug :put_layout, "app2.html"
  plug :clear_main_class
  plug :load_asciicast when action in [:show, :edit, :update, :delete, :example, :iframe, :embed]
  plug :require_current_user when action in [:edit, :update, :delete]
  plug :authorize, :asciicast when action in [:edit, :update, :delete]

  def index(conn, _params) do
    redirect(conn, to: asciicast_path(conn, :category, :featured))
  end

  def category(conn, %{"category" => c} = params) when c in ["featured", "public"] do
    category = String.to_existing_atom(c)
    order = if params["order"] == "popularity", do: :popularity, else: :date

    page = Asciicasts.paginate_asciicasts(category, order, params["page"], 12)

    assigns = [
      page_title: String.capitalize("#{category} asciicasts"),
      category: category,
      page: page,
      order: order
    ]

    render(conn, "category.html", assigns)
  end

  def category(conn, _params) do
    redirect(conn, to: asciicast_path(conn, :category, :featured))
  end

  def show(conn, _params) do
    do_show(conn, get_format(conn), conn.assigns.asciicast)
  end

  def do_show(conn, "html", asciicast) do
    if asciicast.archived_at do
      conn
      |> put_status(410)
      |> render("archived.html")
    else
      conn
      |> count_view(asciicast)
      |> put_archival_info_flash(asciicast)
      |> render(
        "show.html",
        page_title: AsciinemaWeb.AsciicastView.title(asciicast),
        asciicast: asciicast,
        playback_options: Asciicasts.PlaybackOpts.parse(conn.params),
        actions: asciicast_actions(asciicast, conn.assigns.current_user),
        author_asciicasts: Asciicasts.other_public_asciicasts(asciicast)
      )
    end
  end

  def do_show(conn, format, asciicast) when format in ["json", "cast"] do
    if asciicast.archived_at do
      send_resp(conn, 410, "")
    else
      path = Asciicast.json_store_path(asciicast)
      filename = download_filename(asciicast, conn.params)
      file_store().serve_file(conn, path, filename)
    end
  end

  @js_max_age 60

  def do_show(conn, "js", _asciicast) do
    path = Application.app_dir(:asciinema, "priv/static/js/embed.js")

    conn
    |> put_resp_content_type("application/javascript")
    |> put_resp_header("cache-control", "public, max-age=#{@js_max_age}")
    |> send_file(200, path)
  end

  def do_show(conn, "svg", asciicast) do
    if asciicast.archived_at do
      path = Application.app_dir(:asciinema, "priv/static/images/archived.png")

      conn
      |> put_status(410)
      |> put_resp_header("content-type", "image/png")
      |> send_file(200, path)
    else
      render(conn, "show.svg", asciicast: asciicast)
    end
  end

  @png_max_age 604_800 # 7 days

  def do_show(conn, "png", asciicast) do
    if asciicast.archived_at do
      path = Application.app_dir(:asciinema, "priv/static/images/archived.png")

      conn
      |> put_status(410)
      |> put_resp_header("content-type", "image/png")
      |> send_file(200, path)
    else
      case PngGenerator.generate(asciicast) do
        {:ok, png_path} ->
          conn
          |> put_resp_header("content-type", MIME.from_path(png_path))
          |> put_resp_header("cache-control", "public, max-age=#{@png_max_age}")
          |> send_file(200, png_path)
          |> halt

        {:error, :busy} ->
          conn
          |> put_resp_header("retry-after", "5")
          |> send_resp(503, "")
      end
    end
  end

  def do_show(conn, "gif", asciicast) do
    if asciicast.archived_at do
      send_resp(conn, 410, "")
    else
      conn
      |> put_layout("simple.html")
      |> render("gif.html", file_url: asciicast_file_url(conn, asciicast))
    end
  end

  def edit(conn, _params) do
    changeset = Asciicasts.change_asciicast(conn.assigns.asciicast)
    render(conn, "edit.html", changeset: changeset)
  end

  def update(conn, %{"asciicast" => asciicast_params}) do
    asciicast = conn.assigns.asciicast

    case Asciicasts.update_asciicast(asciicast, asciicast_params) do
      {:ok, asciicast} ->
        conn
        |> put_flash(:info, "Asciicast updated.")
        |> redirect(to: asciicast_path(conn, :show, asciicast))

      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, "edit.html", changeset: changeset)
    end
  end

  def delete(conn, _params) do
    asciicast = conn.assigns.asciicast

    case Asciicasts.delete_asciicast(asciicast) do
      {:ok, _asciicast} ->
        conn
        |> put_flash(:info, "Asciicast deleted.")
        |> redirect(to: profile_path(conn, conn.assigns.current_user))

      {:error, _reason} ->
        conn
        |> put_flash(:error, "Oops, couldn't remove this asciicast.")
        |> redirect(to: asciicast_path(conn, :show, asciicast))
    end
  end

  def iframe(conn, _params) do
    conn =
      conn
      |> put_layout("iframe.html")
      |> delete_resp_header("x-frame-options")

    if conn.assigns.asciicast.archived_at do
      conn
      |> put_status(410)
      |> render("archived.html")
    else
      url = asciicast_file_url(conn, conn.assigns.asciicast)
      render(conn, "iframe.html", file_url: url)
    end
  end

  def embed(conn, params) do
    opts = Asciicasts.PlaybackOpts.parse(params)
    conn =
      conn
      |> put_layout("embed.html")
      |> delete_resp_header("x-frame-options")

    if conn.assigns.asciicast.archived_at do
      conn
      |> put_status(410)
      |> render("archived.html")
    else
      render(conn, "embed.html", playback_options: opts)
    end
  end

  def example(conn, _params) do
    home_asciicast = Asciicasts.get_homepage_asciicast()

    conn
    |> put_layout("example.html")
    |> render("example.html", home_asciicast: home_asciicast)
  end

  defp download_filename(%Asciicast{version: version, id: id}, %{"dl" => _}) do
    case version do
      0 -> "#{id}.json"
      1 -> "#{id}.json"
      2 -> "#{id}.cast"
    end
  end

  defp download_filename(_asciicast, _params) do
    nil
  end

  defp file_store do
    Application.get_env(:asciinema, :file_store)
  end

  defp load_asciicast(conn, _) do
    assign(conn, :asciicast, Asciicasts.get_asciicast!(conn.params["id"]))
  end

  defp count_view(conn, asciicast) do
    key = "a#{asciicast.id}"

    case conn.req_cookies[key] do
      nil ->
        Asciicasts.inc_views_count(asciicast)
        put_resp_cookie(conn, key, "1", max_age: 3600*24)

      _ ->
        conn
    end
  end

  @actions [
    :edit, :delete,
    :make_private, :make_public,
    :make_featured, :make_not_featured
  ]

  defp asciicast_actions(asciicast, user) do
    @actions
    |> Enum.filter(&action_applicable?(&1, asciicast))
    |> Enum.filter(&Asciinema.Authorization.can?(user, &1, asciicast))
  end

  defp action_applicable?(action, asciicast) do
    case action do
      :make_private -> !asciicast.private
      :make_public -> asciicast.private
      :make_featured -> !asciicast.featured
      :make_not_featured -> asciicast.featured
      _ -> true
    end
  end

  defp put_archival_info_flash(conn, asciicast) do
    with true <- asciicast.archivable,
         days when not is_nil(days) <- Asciicasts.gc_days(),
         %{} = user <- asciicast.user,
         true <- Accounts.temporary_user?(user),
         true <- Timex.before?(asciicast.created_at, Timex.shift(Timex.now(), days: -days)) do
      put_flash(conn, :error, {:safe, "This recording will be archived soon. More details: blog.asciinema.org/post/archival/"})
    else
      _ -> conn
    end
  end
end

defmodule AsciinemaWeb.AsciicastController do
  use AsciinemaWeb, :controller
  alias Asciinema.{Asciicasts, PngGenerator, Accounts}
  alias Asciinema.Asciicasts.Asciicast

  plug :put_layout, "app2.html"
  plug :clear_main_class
  plug :load_asciicast when action in [:show, :edit, :update, :delete, :example, :iframe, :embed]
  plug :require_current_user when action in [:edit, :update, :delete]
  plug :authorize, :asciicast when action in [:edit, :update, :delete]

  def index(conn, _params) do
    redirect(conn, to: asciicast_path(conn, :category, :featured))
  end

  def category(conn, %{"category" => c} = params) when c in ["featured", "public"] do
    category = String.to_existing_atom(c)
    order = if params["order"] == "popularity", do: :popularity, else: :date

    page = Asciicasts.paginate_asciicasts(category, order, params["page"], 12)

    assigns = [
      page_title: String.capitalize("#{category} asciicasts"),
      category: category,
      page: page,
      order: order
    ]

    render(conn, "category.html", assigns)
  end

  def category(conn, _params) do
    redirect(conn, to: asciicast_path(conn, :category, :featured))
  end

  def show(conn, _params) do
    do_show(conn, get_format(conn), conn.assigns.asciicast)
  end

  def do_show(conn, "html", asciicast) do
    if asciicast.archived_at do
      conn
      |> put_status(410)
      |> render("archived.html")
    else
      conn
      |> count_view(asciicast)
      |> put_archival_info_flash(asciicast)
      |> render(
        "show.html",
        page_title: AsciinemaWeb.AsciicastView.title(asciicast),
        asciicast: asciicast,
        playback_options: Asciicasts.PlaybackOpts.parse(conn.params),
        actions: asciicast_actions(asciicast, conn.assigns.current_user),
        author_asciicasts: Asciicasts.other_public_asciicasts(asciicast)
      )
    end
  end

  def do_show(conn, format, asciicast) when format in ["json", "cast"] do
    if asciicast.archived_at do
      send_resp(conn, 410, "")
    else
      path = Asciicast.json_store_path(asciicast)
      filename = download_filename(asciicast, conn.params)
      file_store().serve_file(conn, path, filename)
    end
  end

  @js_max_age 60

  def do_show(conn, "js", _asciicast) do
    path = Application.app_dir(:asciinema, "priv/static/js/embed.js")

    conn
    |> put_resp_content_type("application/javascript")
    |> put_resp_header("cache-control", "public, max-age=#{@js_max_age}")
    |> send_file(200, path)
  end

  def do_show(conn, "svg", asciicast) do
    if asciicast.archived_at do
      path = Application.app_dir(:asciinema, "priv/static/images/archived.png")

      conn
      |> put_status(410)
      |> put_resp_header("content-type", "image/png")
      |> send_file(200, path)
    else
      render(conn, "show.svg", asciicast: asciicast)
    end
  end

  @png_max_age 604_800 # 7 days

  def do_show(conn, "png", asciicast) do
    if asciicast.archived_at do
      path = Application.app_dir(:asciinema, "priv/static/images/archived.png")

      conn
      |> put_status(410)
      |> put_resp_header("content-type", "image/png")
      |> send_file(200, path)
    else
      case PngGenerator.generate(asciicast) do
        {:ok, png_path} ->
          conn
          |> put_resp_header("content-type", MIME.from_path(png_path))
          |> put_resp_header("cache-control", "public, max-age=#{@png_max_age}")
          |> send_file(200, png_path)
          |> halt

        {:error, :busy} ->
          conn
          |> put_resp_header("retry-after", "5")
          |> send_resp(503, "")
      end
    end
  end

  def do_show(conn, "gif", asciicast) do
    if asciicast.archived_at do
      send_resp(conn, 410, "")
    else
      conn
      |> put_layout("simple.html")
      |> render("gif.html", file_url: asciicast_file_url(conn, asciicast))
    end
  end

  def edit(conn, _params) do
    changeset = Asciicasts.change_asciicast(conn.assigns.asciicast)
    render(conn, "edit.html", changeset: changeset)
  end

  def update(conn, %{"asciicast" => asciicast_params}) do
    asciicast = conn.assigns.asciicast

    case Asciicasts.update_asciicast(asciicast, asciicast_params) do
      {:ok, asciicast} ->
        conn
        |> put_flash(:info, "Asciicast updated.")
        |> redirect(to: asciicast_path(conn, :show, asciicast))

      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, "edit.html", changeset: changeset)
    end
  end

  def delete(conn, _params) do
    asciicast = conn.assigns.asciicast

    case Asciicasts.delete_asciicast(asciicast) do
      {:ok, _asciicast} ->
        conn
        |> put_flash(:info, "Asciicast deleted.")
        |> redirect(to: profile_path(conn, conn.assigns.current_user))

      {:error, _reason} ->
        conn
        |> put_flash(:error, "Oops, couldn't remove this asciicast.")
        |> redirect(to: asciicast_path(conn, :show, asciicast))
    end
  end

  def iframe(conn, _params) do
    conn =
      conn
      |> put_layout("iframe.html")
      |> delete_resp_header("x-frame-options")

    if conn.assigns.asciicast.archived_at do
      conn
      |> put_status(410)
      |> render("archived.html")
    else
      url = asciicast_file_url(conn, conn.assigns.asciicast)
      render(conn, "iframe.html", file_url: url)
    end
  end

  def embed(conn, params) do
    opts = Asciicasts.PlaybackOpts.parse(params)
    conn =
      conn
      |> put_layout("embed.html")
      |> delete_resp_header("x-frame-options")

    if conn.assigns.asciicast.archived_at do
      conn
      |> put_status(410)
      |> render("archived.html")
    else
      render(conn, "embed.html", playback_options: opts)
    end
  end

  def example(conn, _params) do
    home_asciicast = Asciicasts.get_homepage_asciicast()

    conn
    |> put_layout("example.html")
    |> render("example.html", home_asciicast: home_asciicast)
  end

  defp download_filename(%Asciicast{version: version, id: id}, %{"dl" => _}) do
    case version do
      0 -> "#{id}.json"
      1 -> "#{id}.json"
      2 -> "#{id}.cast"
    end
  end

  defp download_filename(_asciicast, _params) do
    nil
  end

  defp file_store do
    Application.get_env(:asciinema, :file_store)
  end

  defp load_asciicast(conn, _) do
    assign(conn, :asciicast, Asciicasts.get_asciicast!(conn.params["id"]))
  end

  defp count_view(conn, asciicast) do
    key = "a#{asciicast.id}"

    case conn.req_cookies[key] do
      nil ->
        Asciicasts.inc_views_count(asciicast)
        put_resp_cookie(conn, key, "1", max_age: 3600*24)

      _ ->
        conn
    end
  end

  @actions [
    :edit, :delete,
    :make_private, :make_public,
    :make_featured, :make_not_featured
  ]

  defp asciicast_actions(asciicast, user) do
    @actions
    |> Enum.filter(&action_applicable?(&1, asciicast))
    |> Enum.filter(&Asciinema.Authorization.can?(user, &1, asciicast))
  end

  defp action_applicable?(action, asciicast) do
    case action do
      :make_private -> !asciicast.private
      :make_public -> asciicast.private
      :make_featured -> !asciicast.featured
      :make_not_featured -> asciicast.featured
      _ -> true
    end
  end

  defp put_archival_info_flash(conn, asciicast) do
    with days when not is_nil(days) <- Asciicasts.gc_days(),
         %{} = user <- asciicast.user,
         true <- Accounts.temporary_user?(user),
         true <- Timex.before?(asciicast.created_at, Timex.shift(Timex.now(), days: -days)) do
      put_flash(conn, :error, {:safe, "This recording will be archived soon. More details: blog.asciinema.org/post/archival/"})
    else
      _ -> conn
    end
  end
end


Tweak jury sheet layout

Tweak jury sheet layout

defmodule JumubaseWeb.Generators.PDFGenerator do
  import Jumubase.Gettext
  import JumubaseWeb.DateHelpers
  import JumubaseWeb.Internal.AppearanceView, only: [acc: 1, instrument_name: 1, non_acc: 1]
  import JumubaseWeb.Internal.ParticipantView, only: [full_name: 1]
  import JumubaseWeb.Internal.PerformanceView, only: [category_name: 1, category_info: 1]
  import JumubaseWeb.Internal.PieceView, only: [duration: 1, person_info: 1]
  alias Jumubase.Showtime.{Appearance, Performance, Piece}

  @border_style "1px solid black"

  @doc """
  Returns PDF jury sheets (one per page) for the given performances.
  """
  def jury_sheets(performances) do
    render_performance_pages(performances) |> generate_pdf("portrait")
  end

  @doc """
  Returns a PDF jury table (used for collecting points) for all given performances.
  """
  def jury_table(performances) do
    render_performance_table(performances) |> generate_pdf("landscape")
  end

  # Private helpers

  defp generate_pdf(body_html, orientation) do
    html = Sneeze.render([:html,
      [:head, [:meta, %{charset: "UTF-8"}]],
      [:body,
        %{style: style(%{
          "font-family" => "DejaVu Sans",
          "font-size" => "16px",
          "line-height" => 1.4,
        })},
        body_html
      ]
    ])

    base_params = ["--disable-smart-shrinking", "--orientation", orientation, "--quiet"]

    # Adjust zoom level to account for different DPI, using Mac (96 dpi) as baseline
    shell_params = case :os.type do
      {:unix, :linux} ->
        base_params ++ ["--zoom", "0.78125"] # 75 dpi / 96 dpi
      _ ->
        base_params
    end

    PdfGenerator.generate_binary!(html,
      page_size: "A4",
      shell_params: shell_params
    )
  end

  defp render_performance_pages(performances) do
    for p <- performances do
      [:div, %{style: style(%{"page-break-before" => "always"})}, [
        [:div, %{style: style(%{"border-bottom" => @border_style})},
          [:p, [
            [:span, category_info(p)],
            [:span, format_datetime(p.stage_time)]
          ] |> to_lines]
        ],
        [:div, %{style: style(%{"margin-top" => "50px"})}, render_appearances(p)],
        [:div, %{style: style(%{"margin-top" => "200px"})}, render_pieces(p)],
      ]]
    end
  end

  defp render_appearances(%Performance{appearances: appearances}) do
    non_acc_div = [:div, non_acc(appearances) |> to_appearance_lines]
    if (acc = acc(appearances)) != [] do
      acc_div = [:div, acc |> to_appearance_lines]
      [non_acc_div, acc_heading(), acc_div]
    else
      non_acc_div
    end
  end

  defp render_appearance(%Appearance{participant: pt} = a) do
    [
      [:b, full_name(pt)],
      [:span, ", #{instrument_name(a.instrument)}"]
    ]
  end

  defp to_appearance_lines(appearances) do
    appearances |> Enum.map(&render_appearance/1) |> to_lines
  end

  defp acc_heading do
    [:div, %{style: style(%{"color" => "#666", "margin-top" => "20px"})}, gettext("accompanied by")]
  end

  defp render_pieces(%Performance{pieces: pieces}) do
    pieces |> Enum.map(&render_piece/1)
  end

  defp render_piece(%Piece{} = pc) do
    [:div, %{style: style(%{"margin-top" => "20px"})}, [
      [:b, person_info(pc)],
      [:span, pc.title],
      [:span, %{style: style(%{"color" => "#666"})},
        "#{duration(pc)} / #{gettext("Epoch")} #{pc.epoch}"
      ],
    ] |> to_lines]
  end

  defp to_lines(items), do: Enum.intersperse(items, [:br])

  defp render_performance_table(performances) do
    table = [:table,
      %{style: style(%{
        "border" => "1px solid black",
        "border-collapse" => "collapse",
        "width" => "100%"})
      },
      [:tr,
        [:th, %{style: cell_style(%{"width" => "auto"})}, gettext("Category")],
        [:th, %{style: cell_style(%{"width" => "1%", "white-space" => "nowrap"})}, gettext("Age group")],
        [:th, %{style: cell_style(%{"width" => "auto"})}, gettext("Participants")],
        [:th, %{style: cell_style(%{"width" => "5%"})}, "J1"],
        [:th, %{style: cell_style(%{"width" => "5%"})}, "J2"],
        [:th, %{style: cell_style(%{"width" => "5%"})}, "J3"],
        [:th, %{style: cell_style(%{"width" => "5%"})}, "J4"],
        [:th, %{style: cell_style(%{"width" => "5%"})}, "J5"],
        [:th, %{style: cell_style(%{"width" => "10%"})}, gettext("Result")],
      ]
    ]
    performance_rows = Enum.map(performances, &render_performance_row/1)
    table ++ performance_rows
  end

  defp render_performance_row(%Performance{} = p) do
    [:tr,
      [:td, %{style: cell_style()}, category_name(p)],
      [:td, %{style: cell_style()}, p.age_group],
      [:td, %{style: cell_style()}, render_list_appearances(p)],
      [:td, %{style: cell_style()}],
      [:td, %{style: cell_style()}],
      [:td, %{style: cell_style()}],
      [:td, %{style: cell_style()}],
      [:td, %{style: cell_style()}],
      [:td, %{style: cell_style()}],
    ]
  end

  defp render_list_appearances(%Performance{appearances: a_list}) do
    a_list |> Enum.map(&render_list_appearance/1) |> Enum.intersperse([:br])
  end

  defp render_list_appearance(%Appearance{participant: pt} = a) do
    [:span, "#{full_name(pt)}, #{instrument_name(a.instrument)}"]
  end

  defp cell_style(style_map \\ %{}) do
    %{
      "border" => @border_style,
      "padding" => "10px",
      "text-align" => "left",
      "vertical-align" => "top"
    }
    |> Map.merge(style_map)
    |> style
  end

  defp style(style_map) do
    style_map
    |> Enum.map(fn {key, value} -> "#{key}: #{value}" end)
    |> Enum.join(";")
  end
end

defmodule JumubaseWeb.Generators.PDFGenerator do
  import Jumubase.Gettext
  import JumubaseWeb.DateHelpers
  import JumubaseWeb.Internal.AppearanceView, only: [acc: 1, instrument_name: 1, non_acc: 1]
  import JumubaseWeb.Internal.ParticipantView, only: [full_name: 1]
  import JumubaseWeb.Internal.PerformanceView, only: [category_name: 1, category_info: 1]
  import JumubaseWeb.Internal.PieceView, only: [duration: 1, person_info: 1]
  alias Jumubase.Showtime.{Appearance, Performance, Piece}

  @border_style "1px solid black"

  @doc """
  Returns PDF jury sheets (one per page) for the given performances.
  """
  def jury_sheets(performances) do
    render_performance_pages(performances) |> generate_pdf("portrait")
  end

  @doc """
  Returns a PDF jury table (used for collecting points) for all given performances.
  """
  def jury_table(performances) do
    render_performance_table(performances) |> generate_pdf("landscape")
  end

  # Private helpers

  defp generate_pdf(body_html, orientation) do
    html = Sneeze.render([:html,
      [:head, [:meta, %{charset: "UTF-8"}]],
      [:body,
        %{style: style(%{
          "font-family" => "DejaVu Sans",
          "font-size" => "16px",
          "line-height" => 1.4
        })},
        body_html
      ]
    ])

    base_params = ["--disable-smart-shrinking", "--orientation", orientation, "--quiet"]

    # Adjust zoom level to account for different DPI, using Mac (96 dpi) as baseline
    shell_params = case :os.type do
      {:unix, :linux} ->
        base_params ++ ["--zoom", "0.78125"] # 75 dpi / 96 dpi
      _ ->
        base_params
    end

    PdfGenerator.generate_binary!(html,
      page_size: "A4",
      shell_params: shell_params
    )
  end

  defp render_performance_pages(performances) do
    for p <- performances do
      [:div, %{style: style(%{"page-break-before" => "always"})}, [
        [:div, %{style: style(%{"border-bottom" => @border_style})},
          [:p, [
            [:span, category_info(p)],
            [:span, format_datetime(p.stage_time)]
          ] |> to_lines]
        ],
        [:div, %{style: style(%{"margin-top" => "100px"})}, render_appearances(p)],
        [:div, %{style: style(%{"margin-top" => "200px"})}, render_pieces(p)],
      ]]
    end
  end

  defp render_appearances(%Performance{appearances: appearances}) do
    non_acc_div = [:div, non_acc(appearances) |> to_appearance_lines]
    if (acc = acc(appearances)) != [] do
      acc_div = [:div, acc |> to_appearance_lines]
      [non_acc_div, acc_heading(), acc_div]
    else
      non_acc_div
    end
  end

  defp render_appearance(%Appearance{participant: pt} = a) do
    [
      [:b, full_name(pt)],
      [:span, ", #{instrument_name(a.instrument)}"]
    ]
  end

  defp to_appearance_lines(appearances) do
    appearances |> Enum.map(&render_appearance/1) |> to_lines
  end

  defp acc_heading do
    [:div, %{style: style(%{"color" => "#666", "margin-top" => "20px"})}, gettext("accompanied by")]
  end

  defp render_pieces(%Performance{pieces: pieces}) do
    pieces |> Enum.map(&render_piece/1)
  end

  defp render_piece(%Piece{} = pc) do
    [:div, %{style: style(%{"margin-top" => "20px"})}, [
      [:b, person_info(pc)],
      [:span, pc.title],
      [:span, %{style: style(%{"color" => "#666"})},
        "#{duration(pc)} / #{gettext("Epoch")} #{pc.epoch}"
      ],
    ] |> to_lines]
  end

  defp to_lines(items), do: Enum.intersperse(items, [:br])

  defp render_performance_table(performances) do
    table = [:table,
      %{style: style(%{
        "border" => "1px solid black",
        "border-collapse" => "collapse",
        "width" => "100%"})
      },
      [:tr,
        [:th, %{style: cell_style(%{"width" => "auto"})}, gettext("Category")],
        [:th, %{style: cell_style(%{"width" => "1%", "white-space" => "nowrap"})}, gettext("Age group")],
        [:th, %{style: cell_style(%{"width" => "auto"})}, gettext("Participants")],
        [:th, %{style: cell_style(%{"width" => "5%"})}, "J1"],
        [:th, %{style: cell_style(%{"width" => "5%"})}, "J2"],
        [:th, %{style: cell_style(%{"width" => "5%"})}, "J3"],
        [:th, %{style: cell_style(%{"width" => "5%"})}, "J4"],
        [:th, %{style: cell_style(%{"width" => "5%"})}, "J5"],
        [:th, %{style: cell_style(%{"width" => "10%"})}, gettext("Result")],
      ]
    ]
    performance_rows = Enum.map(performances, &render_performance_row/1)
    table ++ performance_rows
  end

  defp render_performance_row(%Performance{} = p) do
    [:tr,
      [:td, %{style: cell_style()}, category_name(p)],
      [:td, %{style: cell_style()}, p.age_group],
      [:td, %{style: cell_style()}, render_list_appearances(p)],
      [:td, %{style: cell_style()}],
      [:td, %{style: cell_style()}],
      [:td, %{style: cell_style()}],
      [:td, %{style: cell_style()}],
      [:td, %{style: cell_style()}],
      [:td, %{style: cell_style()}],
    ]
  end

  defp render_list_appearances(%Performance{appearances: a_list}) do
    a_list |> Enum.map(&render_list_appearance/1) |> Enum.intersperse([:br])
  end

  defp render_list_appearance(%Appearance{participant: pt} = a) do
    [:span, "#{full_name(pt)}, #{instrument_name(a.instrument)}"]
  end

  defp cell_style(style_map \\ %{}) do
    %{
      "border" => @border_style,
      "padding" => "10px",
      "text-align" => "left",
      "vertical-align" => "top"
    }
    |> Map.merge(style_map)
    |> style
  end

  defp style(style_map) do
    style_map
    |> Enum.map(fn {key, value} -> "#{key}: #{value}" end)
    |> Enum.join(";")
  end
end


fix test description (#936)

fix test description (#936)

defmodule BoutiqueInventoryTest do
  use ExUnit.Case

  describe "sort_by_price/1" do
    @tag task_id: 1
    test "works for an empty inventory" do
      assert BoutiqueInventory.sort_by_price([]) == []
    end

    @tag task_id: 1
    test "sorts items by price" do
      assert BoutiqueInventory.sort_by_price([
               %{price: 65, name: "Maxi Yellow Summer Dress", quantity_by_size: %{}},
               %{price: 60, name: "Cream Linen Pants", quantity_by_size: %{}},
               %{price: 33, name: "Straw Hat", quantity_by_size: %{}},
               %{price: 60, name: "Brown Linen Pants", quantity_by_size: %{}}
             ]) == [
               %{price: 33, name: "Straw Hat", quantity_by_size: %{}},
               %{price: 60, name: "Cream Linen Pants", quantity_by_size: %{}},
               %{price: 60, name: "Brown Linen Pants", quantity_by_size: %{}},
               %{price: 65, name: "Maxi Yellow Summer Dress", quantity_by_size: %{}}
             ]
    end
  end

  describe "with_missing_price/1" do
    @tag task_id: 2
    test "works for an empty inventory" do
      assert BoutiqueInventory.with_missing_price([]) == []
    end

    @tag task_id: 2
    test "filters out items that do have a price" do
      assert BoutiqueInventory.with_missing_price([
               %{name: "Red Flowery Top", price: 50, quantity_per_size: %{}},
               %{name: "Purple Flowery Top", price: nil, quantity_per_size: %{}},
               %{name: "Bamboo Socks Avocado", price: 10, quantity_per_size: %{}},
               %{name: "Bamboo Socks Palm Trees", price: 10, quantity_per_size: %{}},
               %{name: "Bamboo Socks Kittens", price: nil, quantity_per_size: %{}}
             ]) == [
               %{name: "Purple Flowery Top", price: nil, quantity_per_size: %{}},
               %{name: "Bamboo Socks Kittens", price: nil, quantity_per_size: %{}}
             ]
    end
  end

  describe "increase_quantity/2" do
    @tag task_id: 3
    test "works for an empty quantity map" do
      assert BoutiqueInventory.increase_quantity(
               %{
                 name: "Long Black Evening Dress",
                 price: 105,
                 quantity_by_size: %{}
               },
               1
             ) == %{
               name: "Long Black Evening Dress",
               price: 105,
               quantity_by_size: %{}
             }
    end

    @tag task_id: 3
    test "increases quantity of an item" do
      assert BoutiqueInventory.increase_quantity(
               %{
                 name: "Green Swimming Shorts",
                 price: 46,
                 quantity_by_size: %{s: 1, m: 2, l: 4, xl: 1}
               },
               3
             ) == %{
               name: "Green Swimming Shorts",
               price: 46,
               quantity_by_size: %{s: 4, m: 5, l: 7, xl: 4}
             }
    end
  end

  describe "total_quantity/1" do
    @tag task_id: 4
    test "works for an empty quantity map" do
      assert BoutiqueInventory.total_quantity(%{
               name: "Red Denim Pants",
               price: 77,
               quantity_by_size: %{}
             }) == 0
    end

    @tag task_id: 4
    test "sums up total quantity" do
      assert BoutiqueInventory.total_quantity(%{
               name: "Black Denim Skirt",
               price: 50,
               quantity_by_size: %{s: 4, m: 11, l: 6, xl: 8}
             }) == 29
    end
  end
end

defmodule BoutiqueInventoryTest do
  use ExUnit.Case

  describe "sort_by_price/1" do
    @tag task_id: 1
    test "works for an empty inventory" do
      assert BoutiqueInventory.sort_by_price([]) == []
    end

    @tag task_id: 1
    test "sorts items by price" do
      assert BoutiqueInventory.sort_by_price([
               %{price: 65, name: "Maxi Yellow Summer Dress", quantity_by_size: %{}},
               %{price: 60, name: "Cream Linen Pants", quantity_by_size: %{}},
               %{price: 33, name: "Straw Hat", quantity_by_size: %{}},
               %{price: 60, name: "Brown Linen Pants", quantity_by_size: %{}}
             ]) == [
               %{price: 33, name: "Straw Hat", quantity_by_size: %{}},
               %{price: 60, name: "Cream Linen Pants", quantity_by_size: %{}},
               %{price: 60, name: "Brown Linen Pants", quantity_by_size: %{}},
               %{price: 65, name: "Maxi Yellow Summer Dress", quantity_by_size: %{}}
             ]
    end
  end

  describe "with_missing_price/1" do
    @tag task_id: 2
    test "works for an empty inventory" do
      assert BoutiqueInventory.with_missing_price([]) == []
    end

    @tag task_id: 2
    test "filters out items that do have a price" do
      assert BoutiqueInventory.with_missing_price([
               %{name: "Red Flowery Top", price: 50, quantity_per_size: %{}},
               %{name: "Purple Flowery Top", price: nil, quantity_per_size: %{}},
               %{name: "Bamboo Socks Avocado", price: 10, quantity_per_size: %{}},
               %{name: "Bamboo Socks Palm Trees", price: 10, quantity_per_size: %{}},
               %{name: "Bamboo Socks Kittens", price: nil, quantity_per_size: %{}}
             ]) == [
               %{name: "Purple Flowery Top", price: nil, quantity_per_size: %{}},
               %{name: "Bamboo Socks Kittens", price: nil, quantity_per_size: %{}}
             ]
    end
  end

  describe "increase_quantity/2" do
    @tag task_id: 3
    test "works for an empty quantity map" do
      assert BoutiqueInventory.increase_quantity(
               %{
                 name: "Long Black Evening Dress",
                 price: 105,
                 quantity_by_size: %{}
               },
               1
             ) == %{
               name: "Long Black Evening Dress",
               price: 105,
               quantity_by_size: %{}
             }
    end

    @tag task_id: 3
    test "sorts items by price" do
      assert BoutiqueInventory.increase_quantity(
               %{
                 name: "Green Swimming Shorts",
                 price: 46,
                 quantity_by_size: %{s: 1, m: 2, l: 4, xl: 1}
               },
               3
             ) == %{
               name: "Green Swimming Shorts",
               price: 46,
               quantity_by_size: %{s: 4, m: 5, l: 7, xl: 4}
             }
    end
  end

  describe "total_quantity/1" do
    @tag task_id: 4
    test "works for an empty quantity map" do
      assert BoutiqueInventory.total_quantity(%{
               name: "Red Denim Pants",
               price: 77,
               quantity_by_size: %{}
             }) == 0
    end

    @tag task_id: 4
    test "sums up total quantity" do
      assert BoutiqueInventory.total_quantity(%{
               name: "Black Denim Skirt",
               price: 50,
               quantity_by_size: %{s: 4, m: 11, l: 6, xl: 8}
             }) == 29
    end
  end
end


Only submit grade for resource_review if went directly

defmodule Survey.ResourceController do
  use Survey.Web, :controller

  require Logger
  alias Survey.Resource
  import Prelude
  alias Survey.Resource
  alias Survey.ResourceTag
  alias Survey.Repo

  plug :action

  def add(conn, params) do
    if params["f"] do
      Logger.info("Saving new resource")
      save_to_db(conn, params["f"])
      Survey.Grade.submit_grade(conn, "add_resource", 1.0)
    end

    already = Resource.user_submitted_no(conn.assigns.user.id)
    if already > 0 do
      conn = put_flash(conn, :info,
        "Thank you for submitting #{already} #{resource_word(already)}. Your participation has already been graded. You are welcome to submit more resources, or move on to other parts of the course.")
    end

    sig = conn.assigns.user.sig_id
    tags = ResourceTag.get_tags(sig)

    conn
    |> put_layout("minimal.html")
    |> render "resource.html", tags: tags
  end

  def resource_word(cnt) when cnt > 1, do: "resources"
  def resource_word(cnt), do: "resource"

  #----------------------------------------

  def tag_cloud(conn, params) do
    sig = conn.assigns.user.sig_id
    if params["all"], do: sig = nil
    tagfreq = Resource.tag_freq(sig)
    conn
    |> put_layout("minimal.html")
    |> render "tag_cloud.html", tagfreq: tagfreq
  end

  def list(conn, params) do
    sig = conn.assigns.user.sig_id
    if params["tag"] do
      tag = params["tag"]
      resources = Resource.resource_list(sig, params["tag"])
    else
      tag = nil
      resources = Resource.resource_list(sig)
    end

    conn
    |> put_layout("minimal.html")
    |> render "list.html", resources: resources, tag: tag
  end
  #----------------------------------------

  def review(conn, params) do
    if params["list"] do
      conn = put_session(conn, :review_redirect, true)
      redirect = :list
    else
      redirect = :next
      conn = delete_session(conn, :review_redirect)
    end
    user = conn.assigns.user
    already = Resource.user_reviewed_no(conn.assigns.user.id)
    if already > 0 do
      conn = put_flash(conn, :info,
        "Thank you for reviewing #{already} #{resource_word(already)}. You are welcome to review more resources, or move on to other parts of the course.")
      Survey.Grade.submit_grade(conn, "review_resource", 1.0)
    end

    if params["id"] do
      id = String.to_integer(params["id"])
      Resource.update_seen(user, id)
    else
      id = Resource.get_random(conn.assigns.user)
    end

    if !id do
      html conn, "Sorry, we could not find any new resources for you to review. Try back in a little while."
    else
      sig = conn.assigns.user.sig_id
      tags = ResourceTag.get_tags(sig)
      resource = Resource.get_resource(id)

      if !resource do
        ParamSession.redirect(conn, "/resource/review")
      else
      seen = Resource.user_seen?(user, resource.id)

        rtype = if resource.generic do
          "NOTE: This is a GENERIC Resource, meaning that the person who added it felt that it would be applicable to more than one SIG"
        else
          ""
        end

        conn
        |> put_layout("minimal.html")
        |> render "review.html", tags: tags, resource: resource,
          resourcetype: rtype, redirect: redirect, seen: seen
      end
    end
  end

  def review_submit(conn, params) do
    user = conn.assigns.user
    resource = Repo.get(Resource, params["resource_id"])
    form = params["f"]
    |> Enum.map(fn {k, v} -> {k, String.strip(v)} end)
    |> Enum.into(%{})

    # COMMENTS
    comments = resource.comments || []
    if string_param_exists(form["comment"]) do
      newcom = %{nick: user.nick, user_id: user.id, text: form["comment"],
        date: Ecto.DateTime.local}
      comments = [ newcom | comments ]
    end

    # DESCRIPTION
    description = resource.description
    old_desc = resource.old_desc
    if string_param_exists(form["description"]) &&
      String.strip(form["description"]) != String.strip(resource.description) do
      description = String.strip(form["description"])

      cur = %{description: form["description"], user_id: user.id,
        date: Ecto.DateTime.local}

      # either append, or create old_desc with orig as first entry
      if old_desc do
        old_desc = [ cur | old_desc ]
      else
        orig = %{description: resource.description,
          user_id: resource.user_id, date: resource.inserted_at}
        old_desc = [cur, orig]
      end
    end

    # SCORE
    score = resource.score || 0.0
    old_score = resource.old_score || []
    if string_param_exists(form["rating"]) do
      {newscore, _} = Float.parse(form["rating"])
      new_old_score = %{ "user" => user.id, "score" => newscore,
        "date" => Ecto.DateTime.local }
      old_score = [ new_old_score | old_score ]
      score_sum = old_score
      |> Enum.map(fn x -> x["score"] end)
      |> Enum.sum
      score = score_sum / length(old_score)
    end

    # TAGS
    tags = resource.tags
    if !(resource.generic || !form["tags"] || form["tags"] == "") do
      old_tags = resource.old_tags
      raw_tags = String.split(form["tags"], "|")
      new_tags = set_difference(raw_tags, tags)
      if !Enum.empty?(new_tags) do

        tags = raw_tags
        cur = %{tags: new_tags, user_id: user.id,
          date: Ecto.DateTime.local}

        # either append, or create old_desc with orig as first entry
        if old_tags do
          old_tags = [ cur | old_tags ]
        else
          orig = %{tags: resource.tags,
            user_id: resource.user_id, date: resource.inserted_at}
          old_tags = [cur, orig]
        end
      end
    end

    # INSERT DB
    %{ resource |
      tags: tags, old_tags: old_tags, score: score, old_score: old_score,
      description: description, old_desc: old_desc, comments: comments}
    |> Repo.update!

    redir_url = if get_session(conn, :review_redirect) do
      "/resource/list"
    else
      "/resource/review"
    end
    if get_session(conn, :lti_grade, cache) do
      Survey.Grade.submit_grade(conn, "review_resource", 1.0)
    end

    conn
    |> ParamSession.redirect redir_url
  end
  #----------------------------------------

  def set(x) when is_list(x) do
    Enum.into(x, HashSet.new)
  end

  def different_as_set?(x, y) when is_list(x) and is_list(y) do
    !Enum.empty?(set_difference(x, y))
  end

  def set_difference(x, y) when is_list(x) and is_list(y) do
    Set.difference(set(x), set(y))
    |> Enum.to_list
  end

  def preview(conn, params) do
    tags = ResourceTag.get_tags(2)
    conn
    |> put_layout("minimal.html")
    |> render "resource.html", tags: tags
  end

  def report(conn, _) do
    resources = Resource.get_all_by_sigs
    conn
    |> put_layout("minimal.html")
    |> render "report.html", resources: resources
  end
  #----------------------------------------

  def save_to_db(conn, params) do
    if !Resource.find_url(params["url"], conn.assigns.user.sig_id) do
      resource = params
      |> Map.update("generic", false, fn x -> x == "true" end)
      |> Map.put("user_id", conn.assigns.user.id)
      |> Map.put("sig_id", conn.assigns.user.sig_id)
      |> atomify_map
      |> proc_tags

      struct(Resource, resource)
      |> Repo.insert!

      ResourceTag.update_tags(conn.assigns.user.sig_id, resource.tags)
    else
      Logger.warn("Tried inserting resource with same URL twice")
    end
  end

  defp proc_tags(%{tags: tags} = h), do: %{h | tags: String.split(tags, "|") }
  defp proc_tags(x), do: x

  def check_url(conn, params) do
    sig = conn.assigns.user.sig_id
    url = params["url"] |> String.strip
    if id = Resource.find_url(url, sig) do
      json conn, %{result: "exists", id: id}
    else
      json conn, %{result: "success"}
    end
  end

  defp bad_status(s) do
    s |> Integer.to_string |> String.starts_with?("4")
  end

  defp string_param_exists(s) do
    s && String.strip(s) != ""
  end
end

defmodule Survey.ResourceController do
  use Survey.Web, :controller

  require Logger
  alias Survey.Resource
  import Prelude
  alias Survey.Resource
  alias Survey.ResourceTag
  alias Survey.Repo

  plug :action

  def add(conn, params) do
    if params["f"] do
      Logger.info("Saving new resource")
      save_to_db(conn, params["f"])
      Survey.Grade.submit_grade(conn, "add_resource", 1.0)
    end

    already = Resource.user_submitted_no(conn.assigns.user.id)
    if already > 0 do
      conn = put_flash(conn, :info,
        "Thank you for submitting #{already} #{resource_word(already)}. Your participation has already been graded. You are welcome to submit more resources, or move on to other parts of the course.")
    end

    sig = conn.assigns.user.sig_id
    tags = ResourceTag.get_tags(sig)

    conn
    |> put_layout("minimal.html")
    |> render "resource.html", tags: tags
  end

  def resource_word(cnt) when cnt > 1, do: "resources"
  def resource_word(cnt), do: "resource"

  #----------------------------------------

  def tag_cloud(conn, params) do
    sig = conn.assigns.user.sig_id
    if params["all"], do: sig = nil
    tagfreq = Resource.tag_freq(sig)
    conn
    |> put_layout("minimal.html")
    |> render "tag_cloud.html", tagfreq: tagfreq
  end

  def list(conn, params) do
    sig = conn.assigns.user.sig_id
    if params["tag"] do
      tag = params["tag"]
      resources = Resource.resource_list(sig, params["tag"])
    else
      tag = nil
      resources = Resource.resource_list(sig)
    end

    conn
    |> put_layout("minimal.html")
    |> render "list.html", resources: resources, tag: tag
  end
  #----------------------------------------

  def review(conn, params) do
    if params["list"] do
      conn = put_session(conn, :review_redirect, true)
      redirect = :list
    else
      redirect = :next
      conn = delete_session(conn, :review_redirect)
    end
    user = conn.assigns.user
    already = Resource.user_reviewed_no(conn.assigns.user.id)
    if already > 0 do
      conn = put_flash(conn, :info,
        "Thank you for reviewing #{already} #{resource_word(already)}. You are welcome to review more resources, or move on to other parts of the course.")
      Survey.Grade.submit_grade(conn, "review_resource", 1.0)
    end

    if params["id"] do
      id = String.to_integer(params["id"])
      Resource.update_seen(user, id)
    else
      id = Resource.get_random(conn.assigns.user)
    end

    if !id do
      html conn, "Sorry, we could not find any new resources for you to review. Try back in a little while."
    else
      sig = conn.assigns.user.sig_id
      tags = ResourceTag.get_tags(sig)
      resource = Resource.get_resource(id)

      if !resource do
        ParamSession.redirect(conn, "/resource/review")
      else
      seen = Resource.user_seen?(user, resource.id)

        rtype = if resource.generic do
          "NOTE: This is a GENERIC Resource, meaning that the person who added it felt that it would be applicable to more than one SIG"
        else
          ""
        end

        conn
        |> put_layout("minimal.html")
        |> render "review.html", tags: tags, resource: resource,
          resourcetype: rtype, redirect: redirect, seen: seen
      end
    end
  end

  def review_submit(conn, params) do
    user = conn.assigns.user
    resource = Repo.get(Resource, params["resource_id"])
    form = params["f"]
    |> Enum.map(fn {k, v} -> {k, String.strip(v)} end)
    |> Enum.into(%{})

    # COMMENTS
    comments = resource.comments || []
    if string_param_exists(form["comment"]) do
      newcom = %{nick: user.nick, user_id: user.id, text: form["comment"],
        date: Ecto.DateTime.local}
      comments = [ newcom | comments ]
    end

    # DESCRIPTION
    description = resource.description
    old_desc = resource.old_desc
    if string_param_exists(form["description"]) &&
      String.strip(form["description"]) != String.strip(resource.description) do
      description = String.strip(form["description"])

      cur = %{description: form["description"], user_id: user.id,
        date: Ecto.DateTime.local}

      # either append, or create old_desc with orig as first entry
      if old_desc do
        old_desc = [ cur | old_desc ]
      else
        orig = %{description: resource.description,
          user_id: resource.user_id, date: resource.inserted_at}
        old_desc = [cur, orig]
      end
    end

    # SCORE
    score = resource.score || 0.0
    old_score = resource.old_score || []
    if string_param_exists(form["rating"]) do
      {newscore, _} = Float.parse(form["rating"])
      new_old_score = %{ "user" => user.id, "score" => newscore,
        "date" => Ecto.DateTime.local }
      old_score = [ new_old_score | old_score ]
      score_sum = old_score
      |> Enum.map(fn x -> x["score"] end)
      |> Enum.sum
      score = score_sum / length(old_score)
    end

    # TAGS
    tags = resource.tags
    if !(resource.generic || !form["tags"] || form["tags"] == "") do
      old_tags = resource.old_tags
      raw_tags = String.split(form["tags"], "|")
      new_tags = set_difference(raw_tags, tags)
      if !Enum.empty?(new_tags) do

        tags = raw_tags
        cur = %{tags: new_tags, user_id: user.id,
          date: Ecto.DateTime.local}

        # either append, or create old_desc with orig as first entry
        if old_tags do
          old_tags = [ cur | old_tags ]
        else
          orig = %{tags: resource.tags,
            user_id: resource.user_id, date: resource.inserted_at}
          old_tags = [cur, orig]
        end
      end
    end

    # INSERT DB
    %{ resource |
      tags: tags, old_tags: old_tags, score: score, old_score: old_score,
      description: description, old_desc: old_desc, comments: comments}
    |> Repo.update!

    redir_url = if get_session(conn, :review_redirect) do
      "/resource/list"
    else
      "/resource/review"
    end
    Survey.Grade.submit_grade(conn, "review_resource", 1.0)

    conn
    |> ParamSession.redirect redir_url
  end
  #----------------------------------------

  def set(x) when is_list(x) do
    Enum.into(x, HashSet.new)
  end

  def different_as_set?(x, y) when is_list(x) and is_list(y) do
    !Enum.empty?(set_difference(x, y))
  end

  def set_difference(x, y) when is_list(x) and is_list(y) do
    Set.difference(set(x), set(y))
    |> Enum.to_list
  end

  def preview(conn, params) do
    tags = ResourceTag.get_tags(2)
    conn
    |> put_layout("minimal.html")
    |> render "resource.html", tags: tags
  end

  def report(conn, _) do
    resources = Resource.get_all_by_sigs
    conn
    |> put_layout("minimal.html")
    |> render "report.html", resources: resources
  end
  #----------------------------------------

  def save_to_db(conn, params) do
    if !Resource.find_url(params["url"], conn.assigns.user.sig_id) do
      resource = params
      |> Map.update("generic", false, fn x -> x == "true" end)
      |> Map.put("user_id", conn.assigns.user.id)
      |> Map.put("sig_id", conn.assigns.user.sig_id)
      |> atomify_map
      |> proc_tags

      struct(Resource, resource)
      |> Repo.insert!

      ResourceTag.update_tags(conn.assigns.user.sig_id, resource.tags)
    else
      Logger.warn("Tried inserting resource with same URL twice")
    end
  end

  defp proc_tags(%{tags: tags} = h), do: %{h | tags: String.split(tags, "|") }
  defp proc_tags(x), do: x

  def check_url(conn, params) do
    sig = conn.assigns.user.sig_id
    url = params["url"] |> String.strip
    if id = Resource.find_url(url, sig) do
      json conn, %{result: "exists", id: id}
    else
      json conn, %{result: "success"}
    end
  end

  defp bad_status(s) do
    s |> Integer.to_string |> String.starts_with?("4")
  end

  defp string_param_exists(s) do
    s && String.strip(s) != ""
  end
end