From d2cd9462ba3b68763ef53a333743f51ac344d4d3 Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 14 Aug 2025 12:17:20 +1000 Subject: [PATCH] FEATURE: CRUD access for ai-artifacts (admin only) (#34193) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /admin/plugins/discourse-ai/ai-artifacts.json — list (paginated, per_page capped at 100) - GET /admin/plugins/discourse-ai/ai-artifacts/:id.json — show - POST /admin/plugins/discourse-ai/ai-artifacts.json — create - PUT /admin/plugins/discourse-ai/ai-artifacts/:id.json — update - DELETE /admin/plugins/discourse-ai/ai-artifacts/:id.json — destroy Admin-only, JSON responses. --- .../admin/ai_artifacts_controller.rb | 85 +++++++++++++ .../app/serializers/ai_artifact_serializer.rb | 7 ++ plugins/discourse-ai/config/routes.rb | 8 ++ .../admin/ai_artifacts_controller_spec.rb | 118 ++++++++++++++++++ 4 files changed, 218 insertions(+) create mode 100644 plugins/discourse-ai/app/controllers/discourse_ai/admin/ai_artifacts_controller.rb create mode 100644 plugins/discourse-ai/app/serializers/ai_artifact_serializer.rb create mode 100644 plugins/discourse-ai/spec/requests/admin/ai_artifacts_controller_spec.rb diff --git a/plugins/discourse-ai/app/controllers/discourse_ai/admin/ai_artifacts_controller.rb b/plugins/discourse-ai/app/controllers/discourse_ai/admin/ai_artifacts_controller.rb new file mode 100644 index 00000000000..8ac7ef2e539 --- /dev/null +++ b/plugins/discourse-ai/app/controllers/discourse_ai/admin/ai_artifacts_controller.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +module DiscourseAi + module Admin + class AiArtifactsController < ::Admin::AdminController + requires_plugin ::DiscourseAi::PLUGIN_NAME + + PER_PAGE_MAX = 100 + + before_action :find_artifact, only: %i[show update destroy] + + def index + page = params[:page].to_i + page = 1 if page < 1 + per_page = params[:per_page].to_i + per_page = 50 if per_page < 1 + per_page = PER_PAGE_MAX if per_page > PER_PAGE_MAX + + base = AiArtifact.all + total = base.count + + artifacts = base.order(created_at: :desc).offset((page - 1) * per_page).limit(per_page) + + render json: { + artifacts: serialize_data(artifacts, AiArtifactSerializer), + meta: { + total: total, + page: page, + per_page: per_page, + has_more: total > page * per_page, + }, + } + end + + def show + render_serialized(@artifact, AiArtifactSerializer) + end + + def create + artifact = AiArtifact.new(artifact_params) + + if artifact.save + render_serialized(artifact, AiArtifactSerializer, status: :created) + else + render_json_error artifact + end + end + + def update + if @artifact.update(artifact_params) + render_serialized(@artifact, AiArtifactSerializer) + else + render_json_error @artifact + end + end + + def destroy + if @artifact.destroy + head :no_content + else + render_json_error @artifact + end + end + + private + + def find_artifact + @artifact = AiArtifact.find(params[:id]) + end + + def artifact_params + params.require(:ai_artifact).permit( + :user_id, + :post_id, + :name, + :html, + :css, + :js, + metadata: { + }, + ) + end + end + end +end diff --git a/plugins/discourse-ai/app/serializers/ai_artifact_serializer.rb b/plugins/discourse-ai/app/serializers/ai_artifact_serializer.rb new file mode 100644 index 00000000000..fab9cf880c9 --- /dev/null +++ b/plugins/discourse-ai/app/serializers/ai_artifact_serializer.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AiArtifactSerializer < ApplicationSerializer + attributes :id, :user_id, :post_id, :name, :html, :css, :js, :metadata, :created_at, :updated_at + + self.root = "ai_artifact" +end diff --git a/plugins/discourse-ai/config/routes.rb b/plugins/discourse-ai/config/routes.rb index d3ea44975c6..9a706e0bf94 100644 --- a/plugins/discourse-ai/config/routes.rb +++ b/plugins/discourse-ai/config/routes.rb @@ -70,6 +70,14 @@ Discourse::Application.routes.draw do :constraints => StaffConstraint.new scope "/admin/plugins/discourse-ai", constraints: AdminConstraint.new do + resources :ai_artifacts, + only: %i[index show create update destroy], + path: "ai-artifacts", + controller: "discourse_ai/admin/ai_artifacts", + defaults: { + format: :json, + } + resources :ai_personas, only: %i[index new create edit update destroy], path: "ai-personas", diff --git a/plugins/discourse-ai/spec/requests/admin/ai_artifacts_controller_spec.rb b/plugins/discourse-ai/spec/requests/admin/ai_artifacts_controller_spec.rb new file mode 100644 index 00000000000..83896f05d17 --- /dev/null +++ b/plugins/discourse-ai/spec/requests/admin/ai_artifacts_controller_spec.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +RSpec.describe DiscourseAi::Admin::AiArtifactsController, type: :request do + fab!(:admin) + fab!(:user) + fab!(:target_post) { Fabricate(:post) } + + before do + enable_current_plugin + sign_in(admin) + end + + describe "GET #index" do + fab!(:artifacts) { Fabricate.times(3, :ai_artifact) } + + it "returns paginated list with meta" do + get "/admin/plugins/discourse-ai/ai-artifacts.json", params: { page: 1, per_page: 2 } + + expect(response).to be_successful + json = response.parsed_body + + expect(json["artifacts"]).to be_an(Array) + expect(json["artifacts"].length).to eq(2) + expect(json["meta"]).to include("total", "page", "per_page", "has_more") + expect(json["meta"]["total"]).to eq(AiArtifact.count) + expect(json["meta"]["page"]).to eq(1) + expect(json["meta"]["per_page"]).to eq(2) + expect(json["meta"]["has_more"]).to eq(true) + end + + it "clamps per_page to max" do + get "/admin/plugins/discourse-ai/ai-artifacts.json", params: { per_page: 5000 } + expect(response).to be_successful + expect(response.parsed_body.dig("meta", "per_page")).to eq(100) + end + end + + describe "GET #show" do + fab!(:artifact) { Fabricate(:ai_artifact) } + + it "returns a single artifact" do + get "/admin/plugins/discourse-ai/ai-artifacts/#{artifact.id}.json" + + expect(response).to be_successful + json = response.parsed_body + expect(json.dig("ai_artifact", "id")).to eq(artifact.id) + expect(json.dig("ai_artifact", "name")).to eq(artifact.name) + end + end + + describe "POST #create" do + it "creates an artifact" do + params = { + ai_artifact: { + user_id: admin.id, + post_id: target_post.id, + name: "Admin Created", + html: "
hello
", + css: ".x { color: red; }", + js: "console.log('x')", + metadata: { + public: false, + }, + }, + } + + expect { + post "/admin/plugins/discourse-ai/ai-artifacts.json", + params: params.to_json, + headers: { + "CONTENT_TYPE" => "application/json", + } + }.to change(AiArtifact, :count).by(1) + + expect(response).to have_http_status(:created) + expect(response.parsed_body.dig("ai_artifact", "name")).to eq("Admin Created") + end + end + + describe "PUT #update" do + fab!(:artifact) { Fabricate(:ai_artifact) } + + it "updates fields" do + put "/admin/plugins/discourse-ai/ai-artifacts/#{artifact.id}.json", + params: { ai_artifact: { name: "Updated Name", metadata: { public: true } } }.to_json, + headers: { + "CONTENT_TYPE" => "application/json", + } + + expect(response).to be_successful + artifact.reload + expect(artifact.name).to eq("Updated Name") + expect(artifact.metadata["public"]).to eq(true) + end + end + + describe "DELETE #destroy" do + fab!(:artifact) { Fabricate(:ai_artifact) } + + it "removes the artifact" do + expect { delete "/admin/plugins/discourse-ai/ai-artifacts/#{artifact.id}.json" }.to change( + AiArtifact, + :count, + ).by(-1) + + expect(response).to have_http_status(:no_content) + end + end + + context "when not admin" do + before { sign_in(user) } + + it "blocks access" do + get "/admin/plugins/discourse-ai/ai-artifacts.json" + expect(response.status).to eq(404) + end + end +end