diff --git a/docs/content/docs/Technical/api.md b/docs/content/docs/Technical/api.md index 3a1bacdaa..6e7a2382a 100644 --- a/docs/content/docs/Technical/api.md +++ b/docs/content/docs/Technical/api.md @@ -190,4 +190,48 @@ requests.post( headers={"Authorization": f"Bearer {self.api_token}"}, json={"value": ["Civilian-military interaction", "Protest"], "message": "This is a comment."}, ) -``` \ No newline at end of file +``` + + +### Create an incident +`POST /api/v2/incidents/new` creates a new incident. It has two required parameters: +- `description`, the incident's description. `description` should be a string of at least 8 characters. +- `sensitive`, a string array of the incident's sensitivity. That should be either `["Not Sensitive"]`, or any combination of the values `["Graphic Violence", "Deceptive or Misleading", "Personal Information Visible"]`. + +It also has many optional parameters: +- Any attribute, both core and custom. See below for more information on accessing attributes' API identifiers. +- `status`, the incident's status. By default, the incident will be created as `"To Do"`. If you include this field, you can set the incident to one of: `"To Do"`, `"In Progress"`, `"Ready for Review"`, `"Help Needed"`, `"Completed"`, or "`Canceled"`. +- `restrictions`, an array of the incident's restrictions. If you include this field, you can set the incident to one or both of `["Hidden", "Frozen"]`. +- `tags`, an array of the incident's tags. If you include a value not yet present in the project, that tag will be created and applied to the incident. +- `urls`, which should contain a list of URLs to be archived as distinct pieces of source material. URLs must begin with "https://" or "http://". For more granular control over source material metadata, we recommend using the [source material creation endpoint](#create-a-new-piece-of-source-material) and the [source material metadata update endpoint](#set-source-material-metadata). + +Note that it is not currently possible to set an incident's deleted status or its Assignments from this endpoint. + +Attributes' names in the Atlos interface are different from their API identifiers: +- Core attributes have string names (such as `description` and `status`). +- Custom attributes are identified by a long ID. + +You can find attributes' API identifiers in the **Access** pane of your project. + +```python +requests.post( + f"https://platform.atlos.org/api/v2/incidents/new", + headers={"Authorization": f"Bearer {self.api_token}"}, + json={"description": "Test incident created via the API", + "sensitive": ["Not Sensitive"] + } +) + +requests.post( + f"https://platform.atlos.org/api/v2/incidents/new", + headers={"Authorization": f"Bearer {self.api_token}"}, + json={"description": "Test incident created via the API", + "sensitive": ["Not Sensitive"], + "more_info": "This incident was created via the Atlos API", + "status": "In Progress", + "urls": ["https://docs.atlos.org"], + # This ID is the identifer for the project's multi-select 'Impact' attribute + "cf7a3ed7-2c26-428a-b56c-2cc3f98d7a2c": ["Residential"] + } +) +``` diff --git a/platform/lib/platform/material.ex b/platform/lib/platform/material.ex index be3648c55..ed9474bde 100644 --- a/platform/lib/platform/material.ex +++ b/platform/lib/platform/material.ex @@ -23,6 +23,7 @@ defmodule Platform.Material do alias Platform.Uploads alias Platform.Accounts alias Platform.Permissions + alias Platform.API.APIToken defp hydrate_media_query(query, opts \\ []) do query @@ -315,10 +316,10 @@ defmodule Platform.Material do |> Repo.insert() end - def create_media_audited(%User{} = user, attrs \\ %{}, opts \\ []) do + def create_media_audited(user_or_token, attrs \\ %{}, opts \\ []) when is_struct(user_or_token, User) or is_struct(user_or_token, APIToken) do changeset = %Media{} - |> Media.changeset(attrs, user) + |> Media.changeset(attrs, user_or_token) cond do !changeset.valid? -> @@ -326,7 +327,7 @@ defmodule Platform.Material do true -> Repo.transaction(fn -> - changeset = change_media(%Media{}, attrs, user) + changeset = change_media(%Media{}, attrs, user_or_token) {:ok, media} = changeset @@ -334,40 +335,42 @@ defmodule Platform.Material do if Keyword.get(opts, :post_updates, true) do {:ok, _} = - Updates.change_from_media_creation(media, user) + Updates.change_from_media_creation(media, user_or_token) |> Updates.create_update_from_changeset() end - # Automatically tag new incidents created by regular users, if desirable - user_project_membership = - Projects.get_project_membership_by_user_and_project_id(user, media.project_id) - - {:ok, media} = - with false <- Enum.member?([:owner, :manager], user_project_membership.role), - new_tags_json <- System.get_env("AUTOTAG_USER_INCIDENTS"), - false <- is_nil(new_tags_json) or String.trim(new_tags_json) == "", - {:ok, new_tags} <- Jason.decode(new_tags_json), - false <- Enum.empty?(new_tags) do - {:ok, new_media} = - update_media_attribute_audited( - media, - Attribute.get_attribute(:tags), - Accounts.get_auto_account(), - %{"attr_tags" => (media.attr_tags || []) ++ new_tags} - ) + if is_struct(user_or_token, User) do + # Automatically tag new incidents created by regular users, if desirable + user_project_membership = + Projects.get_project_membership_by_user_and_project_id(user_or_token, media.project_id) + + {:ok, media} = + with false <- Enum.member?([:owner, :manager], user_project_membership.role), + new_tags_json <- System.get_env("AUTOTAG_USER_INCIDENTS"), + false <- is_nil(new_tags_json) or String.trim(new_tags_json) == "", + {:ok, new_tags} <- Jason.decode(new_tags_json), + false <- Enum.empty?(new_tags) do + {:ok, new_media} = + update_media_attribute_audited( + media, + Attribute.get_attribute(:tags), + Accounts.get_auto_account(), + %{"attr_tags" => (media.attr_tags || []) ++ new_tags} + ) + + {:ok, new_media} + else + _ -> {:ok, media} + end - {:ok, new_media} - else - _ -> {:ok, media} + # Subscribe the creator + {:ok, _} = subscribe_user(media, user_or_token) end - # Subscribe the creator - {:ok, _} = subscribe_user(media, user) - # Upload media, if provided for url <- Ecto.Changeset.get_field(changeset, :urls_parsed) do {:ok, version} = - create_media_version_audited(media, user, %{ + create_media_version_audited(media, user_or_token, %{ upload_type: :direct, status: :pending, source_url: url, @@ -561,8 +564,8 @@ defmodule Platform.Material do %Ecto.Changeset{data: %Media{}} """ - def change_media(%Media{} = media, attrs \\ %{}, user \\ nil) do - Media.changeset(media, attrs, user) + def change_media(%Media{} = media, attrs \\ %{}, user_or_token \\ nil) do + Media.changeset(media, attrs, user_or_token) end @doc """ diff --git a/platform/lib/platform/material/media.ex b/platform/lib/platform/material/media.ex index 550141bdf..5fb3e1e60 100644 --- a/platform/lib/platform/material/media.ex +++ b/platform/lib/platform/material/media.ex @@ -6,6 +6,8 @@ defmodule Platform.Material.Media do alias Platform.Material.MediaSubscription alias Platform.Projects alias Platform.Permissions + alias Platform.Accounts.User + alias Platform.API.APIToken alias __MODULE__ @primary_key {:id, :binary_id, autogenerate: true} @@ -95,7 +97,19 @@ defmodule Platform.Material.Media do end @doc false - def changeset(media, attrs, user \\ nil) do + def changeset(media, attrs, user_or_token \\ nil) do + {user, api_token, actor} = + case user_or_token do + %User{} -> + {user_or_token, nil, user_or_token} + + %APIToken{} -> + {nil, user_or_token, user_or_token} + + _ -> + {nil, nil, nil} + end + media |> cast(attrs, [ :attr_description, @@ -105,24 +119,30 @@ defmodule Platform.Material.Media do :deleted, :project_id, :urls, - :location + :location, + :attr_more_info, + :attr_restrictions, + :attr_tags ]) |> validate_required([:project_id], message: "Please select a project") |> populate_geolocation() # These are special attributes, since we define it at creation time. Eventually, it'd be nice to unify this logic with the attribute-specific editing logic. |> Attribute.validate_attribute(Attribute.get_attribute(:description), media, user: user, + api_token: api_token, required: true ) |> Attribute.validate_attribute(Attribute.get_attribute(:sensitive), media, user: user, + api_token: api_token, required: true ) |> Attribute.validate_attribute(Attribute.get_attribute(:date), media, user: user, + api_token: api_token, required: false ) - |> validate_project(user, media) + |> validate_project(media, user_or_token) |> parse_and_validate_validate_json_array(:urls, :urls_parsed) |> validate_url_list(:urls_parsed) |> then(fn cs -> @@ -138,6 +158,7 @@ defmodule Platform.Material.Media do attrs, changeset: cs, user: user, + api_token: api_token, verify_change_exists: false ) end) @@ -151,13 +172,21 @@ defmodule Platform.Material.Media do # We manually insert the project ID because the media hasn't been inserted yet, # so we can't get it from the media itself. Still, we want to check the user's permissions. - if !is_nil(user) && - Permissions.can_edit_media?(user, %{media | project_id: project_id}, attr) do + if !is_nil(actor) && + (case actor do + %User{} -> + Permissions.can_edit_media?(actor, %{media | project_id: project_id}, attr) + %APIToken{} -> + Permissions.can_edit_media?(actor, %{media | project_id: project_id}) + end) do cs # TODO: This is a good refactoring opportunity with the logic above |> cast(attrs, [:attr_tags]) - |> Attribute.validate_attribute(Attribute.get_attribute(:tags), media, + |> Attribute.validate_attribute( + Attribute.get_attribute(:tags), + media, user: user, + api_token: api_token, required: false ) else @@ -209,43 +238,51 @@ defmodule Platform.Material.Media do changeset end - def validate_project(changeset, user \\ nil, media \\ nil) do + @doc """ + Validates changes to a piece of media's project. + """ + def validate_project(changeset, media \\ [], user_or_token \\ :nil) do project_id = Ecto.Changeset.get_change(changeset, :project_id, :no_change) original_project_id = changeset.data.project_id - case project_id do - :no_change -> - changeset + cond do + !is_nil(user_or_token) -> + case project_id do + :no_change -> + changeset - new_project_id -> - new_project = Projects.get_project(new_project_id) - original_project = Projects.get_project(original_project_id) + new_project_id -> + new_project = Projects.get_project(new_project_id) + original_project = Projects.get_project(original_project_id) - cond do - !is_nil(media) && !is_nil(user) && !Permissions.can_edit_media?(user, media) -> - changeset - |> add_error(:project_id, "You cannot edit this incidents's project.") + cond do + !is_nil(media) && !is_nil(user_or_token) && !Permissions.can_edit_media?(user_or_token, media) -> + changeset + |> add_error(:project_id, "You cannot edit this incidents's project.") - !is_nil(project_id) && is_nil(new_project) -> - changeset - |> add_error(:project_id, "Project does not exist") + !is_nil(project_id) && is_nil(new_project) -> + changeset + |> add_error(:project_id, "Project does not exist") - !is_nil(user) && !is_nil(new_project) && - !Permissions.can_add_media_to_project?(user, new_project) -> - changeset - |> add_error(:project_id, "You cannot add incidents to this project.") + !is_nil(user_or_token) && !is_nil(new_project) && + !Permissions.can_add_media_to_project?(user_or_token, new_project) -> + changeset + |> add_error(:project_id, "You cannot add incidents to this project.") - !is_nil(user) && !is_nil(original_project) -> - changeset - |> add_error(:project_id, "You cannot remove media from projects!") + !is_nil(user_or_token) && !is_nil(original_project) -> + changeset + |> add_error(:project_id, "You cannot remove media from projects!") - is_nil(new_project) -> - changeset - |> add_error(:project_id, "You must select a project.") + is_nil(new_project) -> + changeset + |> add_error(:project_id, "You must select a project.") - true -> - changeset + true -> + changeset + end end + true -> + changeset end end @@ -276,7 +313,7 @@ defmodule Platform.Material.Media do def project_changeset(media, attrs, user \\ nil) do media |> cast(attrs, [:project_id]) - |> validate_project(user, media) + |> validate_project(media, user) end @doc """ diff --git a/platform/lib/platform/permissions.ex b/platform/lib/platform/permissions.ex index efe867eaf..b67b77a76 100644 --- a/platform/lib/platform/permissions.ex +++ b/platform/lib/platform/permissions.ex @@ -120,9 +120,16 @@ defmodule Platform.Permissions do token.project_id == media.project_id and _is_media_editable?(media) end + def can_api_token_create_media?(%APIToken{} = token) do + Enum.member?(token.permissions, :edit) and token.is_active + end + def can_api_token_edit_media?(%APIToken{} = token, %Media{} = media) do Enum.member?(token.permissions, :edit) and token.is_active and - token.project_id == media.project_id and _is_media_editable?(media) + _is_media_editable?(media) and + + # If media is being created and doesn't yet have a project, the token should able to give the media a project + (token.project_id == media.project_id or is_nil(media.project_id)) end def can_api_token_update_attribute?( @@ -153,6 +160,10 @@ defmodule Platform.Permissions do end end + def can_add_media_to_project?(%APIToken{} = token, %Project{} = project) do + Enum.member?(token.permissions, :edit) and token.is_active and project.active + end + def can_bulk_upload_media_to_project?(%User{}, %Project{active: false}) do false end diff --git a/platform/lib/platform/updates.ex b/platform/lib/platform/updates.ex index 1eff115b9..48070736a 100644 --- a/platform/lib/platform/updates.ex +++ b/platform/lib/platform/updates.ex @@ -308,14 +308,12 @@ defmodule Platform.Updates do @doc """ Helper API function that takes attribute change information and uses it to create an Update changeset. Requires 'explanation' to be in attrs. """ - def change_from_media_creation(%Media{} = media, %User{} = user) do + def change_from_media_creation(%Media{} = media, %_{} = user_or_token) when is_struct(user_or_token, User) or is_struct(user_or_token, APIToken) do change_update( %Update{}, media, - user, - %{ - "type" => :create - } + user_or_token, + %{"type" => :create} ) end diff --git a/platform/lib/platform_web/controllers/api/api_v2_controller.ex b/platform/lib/platform_web/controllers/api/api_v2_controller.ex index fd8722e4d..16dd696ad 100644 --- a/platform/lib/platform_web/controllers/api/api_v2_controller.ex +++ b/platform/lib/platform_web/controllers/api/api_v2_controller.ex @@ -92,6 +92,62 @@ defmodule PlatformWeb.APIV2Controller do end end + def create_media(conn, params) do + project_id = conn.assigns.token.project_id + project = Projects.get_project!(project_id) + + cond do + not Permissions.can_api_token_create_media?(conn.assigns.token) -> + json(conn |> put_status(401), %{error: "unauthorized"}) + + true -> + # Generate attribute parameters for all attributes in the input + media_params = + params + |> Enum.reduce(%{}, fn {key, value}, acc -> + case Attribute.get_attribute(key, project: project) do + nil -> + acc + + attr -> + # Merge the generated attribute change params + Map.merge( + acc, + Material.generate_attribute_change_params( + attr, + value, + project + ) + ) + end + end) + |> Map.put("project_id", project_id) + + # We expect a JSON array of URLs in the incident creation flow + media_params = case params["urls"] do + nil -> media_params + urls -> Map.put(media_params, "urls", Jason.encode!(urls)) + end + + case Material.create_media_audited(conn.assigns.token, media_params) do + {:ok, media} -> + media_with_project = + Platform.Repo.preload(media, [:project, :versions]) + + Auditor.log( + :media_created, + Map.merge(media_params, %{media_slug: media_with_project.slug}), + conn + ) + + json(conn, %{success: true, result: media_with_project}) + + {:error, changeset} -> + json(conn |> put_status(401), %{error: render_changeset_errors(changeset)}) + end + end + end + def create_media_version(conn, params) do media_id = params["slug"] url = params["url"] @@ -351,19 +407,22 @@ defmodule PlatformWeb.APIV2Controller do end defp render_changeset_errors(changeset) do - Enum.map(changeset.errors, fn {field, detail} -> - {field, render_detail(detail)} + Enum.map(changeset.errors, fn + {field, {"is invalid", [type: {:array, type}, validation: :cast]}} -> + {field, "must be an array of #{type}s"} + + {field, {message, values}} -> + {field, render_detail(message, values)} + + {field, message} -> + {field, message} end) - |> Enum.into(%{}) + |> Map.new() end - defp render_detail({message, values}) do + defp render_detail(message, values) do Enum.reduce(values, message, fn {k, v}, acc -> String.replace(acc, "%{#{k}}", to_string(v)) end) end - - defp render_detail(message) do - message - end end diff --git a/platform/lib/platform_web/live/new_live/new_component.ex b/platform/lib/platform_web/live/new_live/new_component.ex index 6c2016324..0dec94d87 100644 --- a/platform/lib/platform_web/live/new_live/new_component.ex +++ b/platform/lib/platform_web/live/new_live/new_component.ex @@ -113,7 +113,7 @@ defmodule PlatformWeb.NewLive.NewComponent do Material.change_media( socket.assigns.media, media_params, - socket.assigns.current_user + user: socket.assigns.current_user ) ) |> push_redirect(to: "/incidents/#{media.slug}")} diff --git a/platform/lib/platform_web/live/projects_live/api_tokens_component.ex b/platform/lib/platform_web/live/projects_live/api_tokens_component.ex index 1ca8752f6..a617e0e36 100644 --- a/platform/lib/platform_web/live/projects_live/api_tokens_component.ex +++ b/platform/lib/platform_web/live/projects_live/api_tokens_component.ex @@ -390,7 +390,7 @@ defmodule PlatformWeb.ProjectsLive.APITokensComponent do "read" => "Can read incidents and comments, including hidden and restricted incidents", "comment" => "Can add comments to incidents", - "edit" => "Can edit incidents' attributes and metadata" + "edit" => "Can create incidents and edit incidents' attributes and metadata" }), "data-required": Jason.encode!(["read"]) ) %> diff --git a/platform/lib/platform_web/router.ex b/platform/lib/platform_web/router.ex index b14455c0b..dc74585e0 100644 --- a/platform/lib/platform_web/router.ex +++ b/platform/lib/platform_web/router.ex @@ -91,6 +91,7 @@ defmodule PlatformWeb.Router do get("/source_material", APIV2Controller, :media_versions) get("/source_material/:id", APIV2Controller, :media_version) post("/source_material/new/:slug", APIV2Controller, :create_media_version) + post("/incidents/new", APIV2Controller, :create_media) post( "/source_material/metadata/:id/:namespace", diff --git a/platform/test/platform_web/controllers/api_v2_test.exs b/platform/test/platform_web/controllers/api_v2_test.exs index 2d094fb1a..a5e7b447f 100644 --- a/platform/test/platform_web/controllers/api_v2_test.exs +++ b/platform/test/platform_web/controllers/api_v2_test.exs @@ -555,4 +555,113 @@ defmodule PlatformWeb.APIV2Test do %{"success" => true, "result" => ^version} = json_response(conn, 200) end + + test "POST /incidents/new" do + project = project_fixture() + + underpermissioned_token = + api_token_fixture(%{project_id: project.id, permissions: [:read, :comment]}) + + token = api_token_fixture(%{project_id: project.id, permissions: [:read, :comment, :edit]}) + + # Create a piece of media + auth_conn = + build_conn() + |> put_req_header("authorization", "Bearer " <> token.value) + |> post("/api/v2/incidents/new", %{ + "sensitive" => ["Not Sensitive"], + "description" => "Test incident description", + "more_info" => "More info about this incident" + }) + + %{"success" => true, "result" => media} = json_response(auth_conn, 200) + assert media["attr_sensitive"] == ["Not Sensitive"] + assert media["attr_description"] == "Test incident description" + assert media["attr_more_info"] == "More info about this incident" + + # Quickly check that permission validation is working + noauth_conn = post(build_conn(), "/api/v2/incidents/new", %{}) + assert json_response(noauth_conn, 401) == %{"error" => "invalid token or token not found"} + + # Check that underpermissioned tokens can't create incidents + conn = + build_conn() + |> put_req_header("authorization", "Bearer " <> underpermissioned_token.value) + |> post("/api/v2/incidents/new", %{}) + + assert json_response(conn, 401) == %{"error" => "unauthorized"} + + # Check that validation for required attributes is working pt. 1 + conn = + build_conn() + |> put_req_header("authorization", "Bearer " <> token.value) + |> post("/api/v2/incidents/new", %{ + "sensitive" => ["Not Sensitive"] + }) + + assert json_response(conn, 401) == %{"error" => %{"attr_description" => "can't be blank"}} + + # Check that validation for required attributes is working pt. 2 + conn = + build_conn() + |> put_req_header("authorization", "Bearer " <> token.value) + |> post("/api/v2/incidents/new", %{ + "description" => "Test incident name" + }) + + assert json_response(conn, 401) == %{"error" => %{"attr_sensitive" => "can't be blank"}} + + # Check that validation for required attributes is working pt. 3 + conn = + build_conn() + |> put_req_header("authorization", "Bearer " <> token.value) + |> post("/api/v2/incidents/new", %{}) + + assert json_response(conn, 401) == %{ + "error" => %{ + "attr_description" => "can't be blank", + "attr_sensitive" => "can't be blank" + } + } + + # Ensure length validation of required attributes is passed in API response + conn = + build_conn() + |> put_req_header("authorization", "Bearer " <> token.value) + |> post("/api/v2/incidents/new", %{ + "description" => "Short", + "sensitive" => ["Not Sensitive"] + }) + + assert json_response(conn, 401) == %{ + "error" => %{"attr_description" => "should be at least 8 character(s)"} + } + + # Ensure type validation of required attributes is passed in API response + conn = + build_conn() + |> put_req_header("authorization", "Bearer " <> token.value) + |> post("/api/v2/incidents/new", %{ + "description" => "Test incident description", + "sensitive" => "Not Sensitive" + }) + + assert json_response(conn, 401) == %{ + "error" => %{"attr_sensitive" => "must be an array of strings"} + } + + # Ensure type validation of optional attributes is passed in API response + conn = + build_conn() + |> put_req_header("authorization", "Bearer " <> token.value) + |> post("/api/v2/incidents/new", %{ + "description" => "Test incident description", + "sensitive" => ["Not Sensitive"], + "more_info" => ["This More Info section is invalid because", "It's in an array"] + }) + + assert json_response(conn, 401) == %{"error" => %{"attr_more_info" => "is invalid"}} + + # Ensure arbitrary optional attribute values are stored correctly + end end