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 4 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
42 changes: 42 additions & 0 deletions docs/content/docs/Technical/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,4 +190,46 @@ 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.
- `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"]`.
noah-schechter marked this conversation as resolved.
Show resolved Hide resolved

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"`.
noah-schechter marked this conversation as resolved.
Show resolved Hide resolved
- `urls`, which should contain a list of urls to be archived as distinct pieces of source material. For more granular control over source material metadata, we recommend using the field in conjunction with the source material creation endpoint and the source material metadata update endpoint.

Note that it is not currently possible to set an incident's Assignees or deleted status 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"]
}
)
```
53 changes: 50 additions & 3 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,7 +316,9 @@ 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 \\ [])

def create_media_audited(%User{} = user, attrs, opts) do
changeset =
%Media{}
|> Media.changeset(attrs, user)
Expand Down Expand Up @@ -385,6 +388,50 @@ defmodule Platform.Material do
end
end

def create_media_audited(%APIToken{} = api_token, attrs, opts) do
changeset =
%Media{}
|> Media.changeset(attrs, api_token)

cond do
!changeset.valid? ->
{:error, changeset}

true ->
Repo.transaction(fn ->
noah-schechter marked this conversation as resolved.
Show resolved Hide resolved
changeset = change_media(%Media{}, attrs, api_token)

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

if Keyword.get(opts, :post_updates, true) do
{:ok, _} =
Updates.change_from_media_creation(media, api_token)
|> Updates.create_update_from_changeset()
end

# Upload media, if provided
for url <- Ecto.Changeset.get_field(changeset, :urls_parsed) do
{:ok, version} =
create_media_version_audited(media, api_token, %{
upload_type: :direct,
status: :pending,
source_url: url,
media_id: media.id
})

archive_media_version(version)
end

# Schedule metadata generation
schedule_media_auto_metadata_update(media)

media
end)
end
end

def find_raw_slug(slug) do
val = String.split(slug, "-", parts: 2) |> tl() |> Enum.join("-")

Expand Down Expand Up @@ -561,8 +608,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
100 changes: 68 additions & 32 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: user)
|> 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,50 @@ defmodule Platform.Material.Media do
changeset
end

def validate_project(changeset, user \\ nil, media \\ nil) do
def validate_project(changeset, media \\ [], opts) do
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this function is confusing to me — so much is going on. what is the purpose of this function? to make sure that the user or token can edit media within a project?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this function would benefit a lot from either a comment or a rename

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

considering it doesn't operate on projects but media, the name seems confusing at best and wrong at worst

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👻

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(to be clear, I wrote this function)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree the name is cursed.💀 Just added a short comment to reflect that validate_project validates changes to an incident's project, not the project itself.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Working on a deeper change to this function so that we actually do validate that the API Token is permitted to edit the incident's project.)

user = Keyword.get(opts, :user)

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) ->
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) && !Permissions.can_edit_media?(user, 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) && !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) && !is_nil(original_project) ->
changeset
|> add_error(:project_id, "You cannot remove media from projects!")
!is_nil(user) && !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
4 changes: 4 additions & 0 deletions platform/lib/platform/permissions.ex
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,10 @@ 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)
Expand Down
13 changes: 13 additions & 0 deletions platform/lib/platform/updates.ex
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,8 @@ 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, user_or_token)

def change_from_media_creation(%Media{} = media, %User{} = user) do
change_update(
%Update{},
Expand All @@ -319,6 +321,17 @@ defmodule Platform.Updates do
)
end

def change_from_media_creation(%Media{} = media, %APIToken{} = api_token) do
change_update(
%Update{},
media,
api_token,
%{
"type" => :create
}
)
end

@doc """
Helper API function that takes attribute change information and uses it to create an Update changeset.
"""
Expand Down
Loading
Loading