Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add incident creation API endpoint #1056

Draft
wants to merge 18 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 45 additions & 1 deletion docs/content/docs/Technical/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,4 +190,48 @@ requests.post(
headers={"Authorization": f"Bearer {self.api_token}"},
json={"value": ["Civilian-military interaction", "Protest"], "message": "This is a comment."},
)
```
```


### 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.
noah-schechter marked this conversation as resolved.
Show resolved Hide resolved
- `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"]
}
)
```
63 changes: 33 additions & 30 deletions platform/lib/platform/material.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -315,59 +316,61 @@ 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? ->
{:error, changeset}

true ->
Repo.transaction(fn ->
changeset = change_media(%Media{}, attrs, user)
changeset = change_media(%Media{}, attrs, user_or_token)

{:ok, media} =
changeset
|> Repo.insert()

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,
Expand Down Expand Up @@ -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 """
Expand Down
103 changes: 70 additions & 33 deletions platform/lib/platform/material/media.ex
noah-schechter marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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,
Expand All @@ -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 ->
Expand All @@ -138,6 +158,7 @@ defmodule Platform.Material.Media do
attrs,
changeset: cs,
user: user,
api_token: api_token,
verify_change_exists: false
)
end)
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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 """
Expand Down
13 changes: 12 additions & 1 deletion platform/lib/platform/permissions.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
noah-schechter marked this conversation as resolved.
Show resolved Hide resolved
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?(
Expand Down Expand Up @@ -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
Expand Down
8 changes: 3 additions & 5 deletions platform/lib/platform/updates.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading