Skip to content

Commit

Permalink
Custom fields (#29)
Browse files Browse the repository at this point in the history
* feat: custom fields

* feat: manage incident types

* feat: add custom fields

* doc: add security policy

* fix: use factory to create onboarding service

* fix: slack integration commands

* style: ui tweaks

* feat: make datetime columns tz aware

* feat: add some checks
  • Loading branch information
sanjeevan authored Jul 22, 2024
1 parent 0a711ad commit 148ca91
Show file tree
Hide file tree
Showing 76 changed files with 3,384 additions and 426 deletions.
7 changes: 7 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Security Policy

At Incidental, we prioritize the security of our open source project. If you discover a vulnerability, please report it by emailing [email protected] with a detailed description and steps to reproduce. We kindly request that you avoid public disclosure until we've had a chance to address the issue.

We aim to acknowledge reports within 48 hours and provide updates weekly.

Thank you for helping keep Incidental secure.
19 changes: 17 additions & 2 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,20 @@
from sqlalchemy.exc import NoResultFound

from app.exceptions import ApplicationException, ErrorCodes, FormFieldValidationError
from app.routes import forms, health, incidents, organisations, roles, severities, slack, timestamps, users, world
from app.routes import (
fields,
forms,
health,
incident_types,
incidents,
organisations,
roles,
severities,
slack,
timestamps,
users,
world,
)
from app.utils import setup_logger

from .env import settings
Expand Down Expand Up @@ -37,6 +50,8 @@ def create_app() -> FastAPI:
app.include_router(timestamps.router, prefix="/timestamps")
app.include_router(organisations.router, prefix="/organisations")
app.include_router(roles.router, prefix="/roles")
app.include_router(fields.router, prefix="/fields")
app.include_router(incident_types.router, prefix="/incident-types")

# exception handler for form field validation errors
@app.exception_handler(FormFieldValidationError)
Expand Down Expand Up @@ -75,7 +90,7 @@ async def no_result_found_exception_handler(request: Request, err: NoResultFound
status_code=status.HTTP_400_BAD_REQUEST,
content=jsonable_encoder(
{
"detail": "Could not find resource",
"detail": "Could not find resource, please check the ID of any resource in this request.",
"code": ErrorCodes.MODEL_NOT_FOUND,
}
),
Expand Down
6 changes: 4 additions & 2 deletions backend/app/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
# flake8: noqa: F401
from .announcement import Announcement, AnnouncementActions, AnnouncementFields
from .custom_field import CustomField
from .field import Field, FieldKind, InterfaceKind
from .form import Form, FormKind
from .form_field import FormField, FormFieldKind
from .form_field import FormField
from .incident import Incident
from .incident_field_value import IncidentFieldValue
from .incident_role import IncidentRole, IncidentRoleKind
from .incident_role_assignment import IncidentRoleAssignment
from .incident_severity import IncidentSeverity
from .incident_status import IncidentStatus, IncidentStatusCategoryEnum
from .incident_type import IncidentType
from .incident_type_field import IncidentTypeField
from .incident_update import IncidentUpdate
from .organisation import Organisation, OrganisationTypes
from .organisation_member import MemberRole, OrganisationMember
Expand Down
38 changes: 0 additions & 38 deletions backend/app/models/custom_field.py

This file was deleted.

59 changes: 59 additions & 0 deletions backend/app/models/field.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import enum
import typing

from sqlalchemy import Boolean, Enum, ForeignKey, String, UnicodeText
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship

from app.db import Base

from .mixins import SoftDeleteMixin, TimestampMixin

if typing.TYPE_CHECKING:
from .form_field import FormField
from .incident_field_value import IncidentFieldValue
from .organisation import Organisation


class InterfaceKind(str, enum.Enum):
"""What we display the field as in the UI"""

SINGLE_SELECT = "SINGLE_SELECT"
MULTI_SELECT = "MULTI_SELECT"
TEXT = "TEXT"
TEXTAREA = "TEXTAREA"


class FieldKind(str, enum.Enum):
"""What type of data this field will contain"""

USER_DEFINED = "USER_DEFINED" # from `available_options` field

# core options
INCIDENT_NAME = "INCIDENT_NAME"
INCIDENT_TYPE = "INCIDENT_TYPE"
INCIDENT_SEVERITY = "INCIDENT_SEVERITY"
INCIDENT_STATUS = "INCIDENT_STATUS"
INCIDENT_SUMMARY = "INCIDENT_SUMMARY"


class Field(Base, TimestampMixin, SoftDeleteMixin):
__prefix__ = "field"

organisation_id: Mapped[str] = mapped_column(
String(50), ForeignKey("organisation.id", ondelete="cascade"), nullable=False, index=True
)
label: Mapped[str] = mapped_column(UnicodeText, nullable=False)
description: Mapped[str | None] = mapped_column(UnicodeText, nullable=True)
interface_kind: Mapped[InterfaceKind] = mapped_column(Enum(InterfaceKind, native_enum=False), nullable=False)
available_options: Mapped[list[str] | None] = mapped_column(JSONB, nullable=True)
kind: Mapped[FieldKind] = mapped_column(Enum(FieldKind, native_enum=False), nullable=False)

is_deletable: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
is_editable: Mapped[bool] = mapped_column(Boolean, nullable=False)
is_system: Mapped[bool] = mapped_column(Boolean, nullable=False)

# relationships
organisation: Mapped["Organisation"] = relationship("Organisation", back_populates="fields")
form_fields: Mapped[list["FormField"]] = relationship("FormField", back_populates="field")
incident_field_values: Mapped["IncidentFieldValue"] = relationship("IncidentFieldValue", back_populates="field")
2 changes: 2 additions & 0 deletions backend/app/models/form.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

if typing.TYPE_CHECKING:
from .form_field import FormField
from .organisation import Organisation


class FormKind(enum.Enum):
Expand Down Expand Up @@ -38,3 +39,4 @@ class Form(Base, TimestampMixin, SoftDeleteMixin):
order_by="asc(FormField.position)",
viewonly=True,
)
organisation: Mapped["Organisation"] = relationship("Organisation", viewonly=True)
27 changes: 4 additions & 23 deletions backend/app/models/form_field.py
Original file line number Diff line number Diff line change
@@ -1,44 +1,25 @@
import enum
import typing
from typing import Optional

from sqlalchemy import Boolean, Enum, ForeignKey, Integer, String, UnicodeText
from sqlalchemy import Boolean, ForeignKey, Integer, String, UnicodeText
from sqlalchemy.orm import Mapped, mapped_column, relationship

from app.db import Base

from .mixins import SoftDeleteMixin, TimestampMixin

if typing.TYPE_CHECKING:
from .custom_field import CustomField
from .field import Field
from .form import Form


class FormFieldKind(str, enum.Enum):
# generic
SINGLE_SELECT = "SINGLE_SELECT"
MULTI_SELECT = "MULTI_SELECT"
TEXT = "TEXT"
TEXTAREA = "TEXTAREA"

# specific
INCIDENT_TYPE = "INCIDENT_TYPE"
SEVERITY_TYPE = "SEVERITY_TYPE"
INCIDENT_STATUS = "INCIDENT_STATUS"


class FormField(Base, TimestampMixin, SoftDeleteMixin):
__prefix__ = "ff"

form_id: Mapped[str] = mapped_column(
String(50), ForeignKey("form.id", ondelete="cascade"), nullable=False, index=True
)
custom_field_id: Mapped[Optional[str]] = mapped_column(
String(50), ForeignKey("custom_field.id", ondelete="cascade"), nullable=True, index=True
)
kind: Mapped[FormFieldKind] = mapped_column(Enum(FormFieldKind, native_enum=False), nullable=False)
field_id: Mapped[str] = mapped_column(String(50), ForeignKey("field.id", ondelete="cascade"), index=True)
label: Mapped[str] = mapped_column(UnicodeText, nullable=False)
name: Mapped[str] = mapped_column(UnicodeText, nullable=False)
description: Mapped[str | None] = mapped_column(UnicodeText, nullable=True)
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
is_required: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
Expand All @@ -47,4 +28,4 @@ class FormField(Base, TimestampMixin, SoftDeleteMixin):

# relationships
form: Mapped["Form"] = relationship("Form", back_populates="form_fields")
custom_field: Mapped[Optional["CustomField"]] = relationship("CustomField", back_populates="form_fields")
field: Mapped["Field"] = relationship("Field", back_populates="form_fields")
4 changes: 4 additions & 0 deletions backend/app/models/incident.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from .mixins import SoftDeleteMixin, TimestampMixin

if typing.TYPE_CHECKING:
from .incident_field_value import IncidentFieldValue
from .incident_role import IncidentRoleKind
from .incident_role_assignment import IncidentRoleAssignment
from .incident_severity import IncidentSeverity
Expand Down Expand Up @@ -59,6 +60,9 @@ class Incident(Base, TimestampMixin, SoftDeleteMixin):
)
organisation: Mapped["Organisation"] = relationship("Organisation", back_populates="incidents")
timestamp_values: Mapped[list["TimestampValue"]] = relationship("TimestampValue", back_populates="incident")
incident_field_values: Mapped[list["IncidentFieldValue"]] = relationship(
"IncidentFieldValue", back_populates="incident"
)

__table_args__ = (
UniqueConstraint("reference_id", "organisation_id", name="ux_incident_reference_id_organisation_id"),
Expand Down
33 changes: 33 additions & 0 deletions backend/app/models/incident_field_value.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import typing

from sqlalchemy import ForeignKey, String, UniqueConstraint
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship

from app.db import Base

from .mixins import SoftDeleteMixin, TimestampMixin

if typing.TYPE_CHECKING:
from .field import Field
from .incident import Incident


class IncidentFieldValue(Base, TimestampMixin, SoftDeleteMixin):
__prefix__ = "ifv"

incident_id: Mapped[str] = mapped_column(
String(50), ForeignKey("incident.id", ondelete="cascade"), nullable=False, index=True
)
field_id: Mapped[str] = mapped_column(String(50), ForeignKey("field.id", ondelete="cascade"), index=True)

# store values here, where value is stored depends on the field type
value_text: Mapped[str | None] = mapped_column(String, nullable=True)
value_single_select: Mapped[str | None] = mapped_column(String, nullable=True)
value_multi_select: Mapped[list[str] | None] = mapped_column(JSONB, nullable=True)

# relationships
incident: Mapped["Incident"] = relationship("Incident", back_populates="incident_field_values")
field: Mapped["Field"] = relationship("Field", back_populates="incident_field_values")

__table_args__ = (UniqueConstraint("incident_id", "field_id"),)
22 changes: 20 additions & 2 deletions backend/app/models/incident_type.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
from sqlalchemy import ForeignKey, String, UnicodeText
from sqlalchemy.orm import Mapped, mapped_column
import typing

from sqlalchemy import Boolean, ForeignKey, String, UnicodeText
from sqlalchemy.orm import Mapped, mapped_column, relationship

from app.db import Base

from .mixins import SoftDeleteMixin, TimestampMixin

if typing.TYPE_CHECKING:
from .field import Field
from .organisation import Organisation


class IncidentType(Base, TimestampMixin, SoftDeleteMixin):
__prefix__ = "type"
Expand All @@ -14,3 +20,15 @@ class IncidentType(Base, TimestampMixin, SoftDeleteMixin):
)
name: Mapped[str] = mapped_column(UnicodeText, nullable=False)
description: Mapped[str] = mapped_column(UnicodeText, nullable=False)
is_editable: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
is_deletable: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)

# relationships
organisation: Mapped["Organisation"] = relationship("Organisation", back_populates="incident_types")

# fields that have not been deleted
fields: Mapped[list["Field"]] = relationship(
"Field",
secondary="incident_type_field",
secondaryjoin="and_(Field.id==incident_type_field.c.field_id, Field.deleted_at.is_(None))",
)
19 changes: 19 additions & 0 deletions backend/app/models/incident_type_field.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column

from app.db import Base

from .mixins import SoftDeleteMixin, TimestampMixin


class IncidentTypeField(Base, TimestampMixin, SoftDeleteMixin):
"""Which fields are available for an incident type"""

__prefix__ = "itf"

incident_type_id: Mapped[str] = mapped_column(
String(50), ForeignKey("incident_type.id", ondelete="cascade"), nullable=False, index=True
)
field_id: Mapped[str] = mapped_column(
String(50), ForeignKey("field.id", ondelete="cascade"), nullable=False, index=True
)
6 changes: 3 additions & 3 deletions backend/app/models/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@
class TimestampMixin:
"""Adds timestamp fields to model"""

created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=datetime.now)
updated_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow
DateTime(timezone=True), nullable=False, default=datetime.now, onupdate=datetime.now
)


class SoftDeleteMixin:
"""Adds a soft delete field"""

deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True, default=None)
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True, default=None)
6 changes: 4 additions & 2 deletions backend/app/models/organisation.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@

if typing.TYPE_CHECKING:
from .announcement import Announcement
from .custom_field import CustomField
from .field import Field
from .incident import Incident
from .incident_role import IncidentRole
from .incident_severity import IncidentSeverity
from .incident_type import IncidentType
from .settings import Settings
from .slack_message import SlackMessage
from .timestamp import Timestamp
Expand All @@ -38,7 +39,7 @@ class Organisation(Base, TimestampMixin):
slack_bot_token = mapped_column(UnicodeText, nullable=True)

# relationships
custom_fields: Mapped[list["CustomField"]] = relationship("CustomField", back_populates="organisation")
fields: Mapped[list["Field"]] = relationship("Field", back_populates="organisation")
incident_severities: Mapped[list["IncidentSeverity"]] = relationship(
"IncidentSeverity", back_populates="organisation"
)
Expand All @@ -49,6 +50,7 @@ class Organisation(Base, TimestampMixin):
announcements: Mapped[list["Announcement"]] = relationship("Announcement", back_populates="organisation")
incidents: Mapped[list["Incident"]] = relationship("Incident", back_populates="organisation")
timestamps: Mapped[list["Timestamp"]] = relationship("Timestamp", back_populates="organisation")
incident_types: Mapped[list["IncidentType"]] = relationship("IncidentType", back_populates="organisation")

def __repr__(self):
return f"<Organisation id={self.id} name={self.name}>"
Expand Down
4 changes: 2 additions & 2 deletions backend/app/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ class User(Base, TimestampMixin):
_settings = mapped_column("settings", JSONB(none_as_null=True), nullable=False, default={})
is_email_verified = mapped_column(Boolean, nullable=False, default=False)
login_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
last_login_at: Mapped[datetime] = mapped_column(DateTime, nullable=True)
last_login_attempt_at = mapped_column(DateTime, nullable=True)
last_login_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
last_login_attempt_at = mapped_column(DateTime(timezone=True), nullable=True)

# slack specific
slack_user_id = mapped_column(UnicodeText, nullable=True, unique=True)
Expand Down
Loading

0 comments on commit 148ca91

Please sign in to comment.