diff --git a/lib/atomic/activities.ex b/lib/atomic/activities.ex index f6e5704c6..77c8af685 100644 --- a/lib/atomic/activities.ex +++ b/lib/atomic/activities.ex @@ -300,6 +300,12 @@ defmodule Atomic.Activities do Repo.all(Enrollment) end + def list_enrollments(opts) when is_list(opts) do + Enrollment + |> apply_filters(opts) + |> Repo.all() + end + @doc """ Gets a single enrollment. diff --git a/lib/atomic/organizations/certificate.ex b/lib/atomic/organizations/certificate.ex new file mode 100644 index 000000000..2cc265af0 --- /dev/null +++ b/lib/atomic/organizations/certificate.ex @@ -0,0 +1,28 @@ +defmodule Atomic.Certificate do + @moduledoc false + use Atomic.Schema + + alias Atomic.Organizations.Organization + + @required_fields ~w(background title content background_color title_color content_color organization_color organization_id)a + + schema "certificates" do + field :title, :integer + field :background, :boolean, default: false + field :content, :string + field :background_color, :string + field :title_color, :string + field :content_color, :string + field :organization_color, :string + belongs_to :organization, Organization + + timestamps() + end + + @doc false + def changeset(certificate, attrs) do + certificate + |> cast(attrs, @required_fields) + |> validate_required(@required_fields) + end +end diff --git a/lib/atomic/organizations/organization.ex b/lib/atomic/organizations/organization.ex index 9db7602bb..079cbb46c 100644 --- a/lib/atomic/organizations/organization.ex +++ b/lib/atomic/organizations/organization.ex @@ -1,14 +1,14 @@ defmodule Atomic.Organizations.Organization do @moduledoc false use Atomic.Schema - alias Atomic.Accounts.User + alias Atomic.Certificate alias Atomic.Location alias Atomic.Organizations.{Announcement, Department, Membership, Partner} alias Atomic.Uploaders @required_fields ~w(name long_name description)a - @optional_fields ~w()a + @optional_fields ~w(certificate_template_id)a @derive { Flop.Schema, @@ -45,6 +45,8 @@ defmodule Atomic.Organizations.Organization do many_to_many :users, User, join_through: Membership + belongs_to :certificate_template, Certificate + timestamps() end diff --git a/lib/atomic/quantum/certificate_delivery.ex b/lib/atomic/quantum/certificate_delivery.ex index 373f7d621..a04b116e5 100644 --- a/lib/atomic/quantum/certificate_delivery.ex +++ b/lib/atomic/quantum/certificate_delivery.ex @@ -65,11 +65,11 @@ defmodule Atomic.Quantum.CertificateDelivery do # It uses `wkhtmltopdf` to build it from an HTML template, which # is rendered beforehand. - defp generate_certificate( - %Enrollment{} = enrollment, - %Activity{} = activity, - %Organization{} = organization - ) do + def generate_certificate( + %Enrollment{} = enrollment, + %Activity{} = activity, + %Organization{} = organization + ) do # Create the string corresponding to the HTML to convert # to a PDF Phoenix.View.render_to_string(AtomicWeb.PDFView, "activity_certificate.html", @@ -96,6 +96,39 @@ defmodule Atomic.Quantum.CertificateDelivery do ) end + def generate_certificate( + %Enrollment{} = enrollment, + %Activity{} = activity, + %Organization{} = organization, + certificate_options \\ %{} + ) do + # Create the string corresponding to the HTML to convert + # to a PDF + Phoenix.View.render_to_string(AtomicWeb.PDFView, "activity_certificate.html", + enrollment: enrollment, + activity: activity, + organization: organization, + certificate_options: certificate_options + ) + |> PdfGenerator.generate( + delete_temporary: true, + page_size: "A4", + filename: "certificate_#{enrollment.id}", + shell_params: [ + "--margin-top", + "0", + "--margin-left", + "0", + "--margin-right", + "0", + "--margin-bottom", + "0", + "-O", + "landscape" + ] + ) + end + # Builds the query to determine the activities to consider for certificate # delivery. diff --git a/lib/atomic_web/live/organization_live/certificate_live/index.ex b/lib/atomic_web/live/organization_live/certificate_live/index.ex new file mode 100644 index 000000000..b6e374a0e --- /dev/null +++ b/lib/atomic_web/live/organization_live/certificate_live/index.ex @@ -0,0 +1,144 @@ +defmodule AtomicWeb.OrganizationLive.CertificateLive.Index do + use AtomicWeb, :live_view + + alias Atomic.Activities + alias Atomic.Certificate + alias Atomic.Organizations + import AtomicWeb.Components.Forms + + @impl true + def mount(_params, _session, socket) do + certificate_options = %{ + background: true, + title: 62, + content: "Para os devidos efeitos, certifica-se que participou na atividade", + background_color: "#ffffff", + title_color: "#fb923c", + content_color: "#000000" + } + + {:ok, assign(socket, certificate_options: certificate_options)} + end + + @impl true + def handle_params(%{"organization_id" => organization_id} = params, _url, socket) do + activities = list_activities(organization_id) + organization = Organizations.get_organization!(organization_id) + + default_options = %{ + background: true, + title: 62, + content: "Para os devidos efeitos, certifica-se que participou na atividade", + background_color: "#ffffff", + title_color: "#fb923c", + content_color: "#000000" + } + + certificate_options = default_options + + changeset = + Certificate.changeset(%Certificate{}, %{ + organization_id: organization_id, + background: default_options.background, + title: default_options.title, + content: default_options.content, + background_color: default_options.background_color, + title_color: default_options.title_color, + content_color: default_options.content_color + }) + + certificate = %Certificate{} + + {:noreply, + socket + |> assign(:page_title, gettext("Certificate")) + |> assign(:current_page, :certificate) + |> assign(:changeset, changeset) + |> assign(:certificate, certificate) + |> assign(:activities, activities) + |> assign(:organization, organization) + |> assign(:certificate_options, certificate_options) + |> assign(:params, params)} + end + + @impl true + def handle_event("validate", %{"certificate" => certificate_params}, socket) do + certificate_options = extract_certificate_options(certificate_params) + + changeset = + (socket.assigns.certificate || %Certificate{}) + |> Certificate.changeset( + Map.put(certificate_params, "organization_id", socket.assigns.organization.id) + ) + |> Map.put(:action, :validate) + + {:noreply, + socket + |> assign(:changeset, changeset) + |> assign(:certificate_options, certificate_options)} + end + + @impl true + def handle_event("save", _params, socket) do + organization_id = socket.assigns.organization.id + + certificate_params = %{ + "organization_id" => organization_id, + "background" => socket.assigns.certificate_options.background, + "title" => socket.assigns.certificate_options.title, + "content" => socket.assigns.certificate_options.content, + "background_color" => socket.assigns.certificate_options.background_color, + "title_color" => socket.assigns.certificate_options.title_color, + "content_color" => socket.assigns.certificate_options.content_color + } + + case %Certificate{} |> Certificate.changeset(certificate_params) |> Atomic.Repo.insert() do + {:ok, _certificate} -> + case Organizations.update_organization(socket.assigns.organization, %{ + certificate_template_id: socket.assigns.certificate.id + }) do + {:ok, _organization} -> + {:noreply, + socket + |> put_flash(:info, "Certificate saved and organization updated successfully!")} + + {:error, %Ecto.Changeset{} = _changeset} -> + {:noreply, + socket |> put_flash(:error, "Certificate saved, but failed to update organization")} + end + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, + socket |> assign(:changeset, changeset) |> put_flash(:error, "Error saving certificate")} + end + end + + defp list_activities(organization_id) do + case Activities.list_activities_by_organization_id(organization_id) do + {:ok, {activities, _meta}} -> activities + {:error, _flop} -> [] + end + end + + defp extract_certificate_options(params) do + %{ + background: Map.get(params, "background") == "true", + title: parse_integer(Map.get(params, "title"), 62), + content: + Map.get(params, "content") || + "Para os devidos efeitos, certifica-se que participou na atividade", + background_color: Map.get(params, "background_color") || "#ffffff", + title_color: Map.get(params, "title_color") || "#fb923c", + content_color: Map.get(params, "content_color") || "#000000" + } + end + + defp parse_integer(value, default) when is_binary(value) do + case Integer.parse(value) do + {int, _} -> int + :error -> default + end + end + + defp parse_integer(_, default), do: default +end diff --git a/lib/atomic_web/live/organization_live/certificate_live/index.html.heex b/lib/atomic_web/live/organization_live/certificate_live/index.html.heex new file mode 100644 index 000000000..e29c30cb4 --- /dev/null +++ b/lib/atomic_web/live/organization_live/certificate_live/index.html.heex @@ -0,0 +1,27 @@ +<.page title="Certificate"> + <:actions> + <.button size={:md} color={:white} icon="hero-cube" type="button" phx-click="save"> + {gettext("Save Certificate")} + + + + <.form :let={f} for={@changeset} id="certificate-form" phx-change="validate" class="flex flex-row items-start gap-8"> +
+

Display Elements

+ <.field field={f[:background]} type="switch" label="Show Background" required class="mb-4 w-full" value={@certificate_options.background} /> + <.field field={f[:title]} type="number" label="Title Size" required class="mb-4 w-full" value={@certificate_options.title} /> + <.field field={f[:content]} type="textarea" label="Content text" required class="mb-4 w-full" value={@certificate_options.content} /> + +

Color Options

+ <.field field={f[:background_color]} type="color" label="Background Color" required class="mb-4 w-full" value={@certificate_options.background_color} /> + <.field field={f[:title_color]} type="color" label="Title Color" required class="mb-4 w-full" value={@certificate_options.title_color} /> + <.field field={f[:content_color]} type="color" label="Content Color" required class="mb-4 w-full" value={@certificate_options.content_color} /> +
+ +
+
+ {Phoenix.View.render(AtomicWeb.CertificateView, "main.html", Map.put(assigns, :certificate_options, @certificate_options) |> Map.put(:organization, @organization))} +
+
+ + diff --git a/lib/atomic_web/live/organization_live/show.html.heex b/lib/atomic_web/live/organization_live/show.html.heex index 328614eed..e89fd6bb3 100644 --- a/lib/atomic_web/live/organization_live/show.html.heex +++ b/lib/atomic_web/live/organization_live/show.html.heex @@ -53,7 +53,7 @@ phx-click="unfollow" @click.away="open = false" @click="open = false" - class="absolute left-0 z-10 mt-2 -mr-1 w-72 origin-top-left divide-y divide-zinc-200 overflow-hidden rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:left-0 sm:left-auto" + class="absolute left-0 z-10 mt-2 -mr-1 w-72 origin-top-left divide-y divide-zinc-200 overflow-hidden rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:left-auto" tabindex="-1" role="listbox" aria-labelledby="listbox-label" @@ -81,6 +81,11 @@ <.icon name="hero-trash-solid" class="mr-3 h-5 w-5 text-zinc-400" /> Delete <% end %> + <.link patch={~p"/organizations/#{@organization}/certificate"} class="button"> + + <% end %> diff --git a/lib/atomic_web/router.ex b/lib/atomic_web/router.ex index ee7abf568..3186088de 100644 --- a/lib/atomic_web/router.ex +++ b/lib/atomic_web/router.ex @@ -58,6 +58,7 @@ defmodule AtomicWeb.Router do scope "/organizations/:organization_id" do live "/edit", OrganizationLive.Edit, :edit + live "/certificate", OrganizationLive.CertificateLive.Index, :index scope "/activities" do pipe_through :confirm_activity_association diff --git a/lib/atomic_web/templates/certificate/main.html.eex b/lib/atomic_web/templates/certificate/main.html.eex new file mode 100644 index 000000000..11c120757 --- /dev/null +++ b/lib/atomic_web/templates/certificate/main.html.eex @@ -0,0 +1,83 @@ + + + + Certificado da Atividade + + + + + +
+
Certificado de Participação
+

+ <%= Map.get(@certificate_options, :content, "Para os devidos efeitos, certifica-se que participou na atividade") %> +

+ Nome +

+ Atividade, organizada pelo(a) <%= @organization.name %>. +

+ + +

+ Este certificado foi gerado automaticamente pela plataforma open-source de gestão de núcleos Atomic, sendo da exclusiva responsabilidade do Centro de Estudantes de Engenharia de Informática da Universidade do Minho, com a devida autorização da organização dinamizadora da atividade. +

+
+ + \ No newline at end of file diff --git a/lib/atomic_web/views/certificate_view.ex b/lib/atomic_web/views/certificate_view.ex new file mode 100644 index 000000000..9fe271bfe --- /dev/null +++ b/lib/atomic_web/views/certificate_view.ex @@ -0,0 +1,3 @@ +defmodule AtomicWeb.CertificateView do + use AtomicWeb, :view +end diff --git a/priv/repo/migrations/2025043221721_create_certificates.exs b/priv/repo/migrations/2025043221721_create_certificates.exs new file mode 100644 index 000000000..4e6e4e609 --- /dev/null +++ b/priv/repo/migrations/2025043221721_create_certificates.exs @@ -0,0 +1,27 @@ +defmodule Atomic.Repo.Migrations.CreateCertificates do + use Ecto.Migration + + def change do + create table(:certificates, primary_key: false) do + add :id, :binary_id, primary_key: true + + add :background, :boolean, default: false, null: false + add :title, :integer + add :content, :text + add :organization, :boolean, default: false, null: false + add :background_color, :string + add :title_color, :string + add :content_color, :string + add :organization_color, :string + add :organization_id, references(:organizations, type: :binary_id), null: false + + timestamps() + end + + create index(:certificates, [:organization_id]) + + alter table(:organizations) do + add :certificate_template_id, references(:certificates, type: :binary_id), null: true + end + end +end