diff --git a/api/auth/auth_project.py b/api/auth/auth_project.py index 6f842916b..0f28b4162 100644 --- a/api/auth/auth_project.py +++ b/api/auth/auth_project.py @@ -17,8 +17,8 @@ class ProjectAuthorizer: current_user: schemas.CurrentContext = await OR_context(request) value = request.path_params[self.project_identifier] if (self.project_identifier == "projectId" \ - and not (isinstance(value, int) or isinstance(value, str) and value.isnumeric()) - and projects.get_project(project_id=value, tenant_id=current_user.tenant_id) is None) \ + and (not (isinstance(value, int) or isinstance(value, str) and value.isnumeric()) + or projects.get_project(project_id=value, tenant_id=current_user.tenant_id) is None)) \ or (self.project_identifier == "projectKey" \ and projects.get_internal_project_id(project_key=value) is None): print("project not found") diff --git a/api/chalicelib/core/sessions_notes.py b/api/chalicelib/core/sessions_notes.py new file mode 100644 index 000000000..916af221a --- /dev/null +++ b/api/chalicelib/core/sessions_notes.py @@ -0,0 +1,105 @@ +import json + +import schemas +from chalicelib.core import users +from chalicelib.utils import pg_client, helper, dev +from chalicelib.utils.TimeUTC import TimeUTC + + +def get_session_notes(tenant_id, project_id, session_id, user_id): + with pg_client.PostgresClient() as cur: + query = cur.mogrify(f"""SELECT sessions_notes.* + FROM sessions_notes + INNER JOIN users USING (user_id) + WHERE sessions_notes.project_id = %(project_id)s + AND sessions_notes.deleted_at IS NULL + AND sessions_notes.session_id = %(session_id)s + AND (sessions_notes.user_id = %(user_id)s + OR sessions_notes.is_public AND users.tenant_id = %(tenant_id)s) + ORDER BY created_at DESC;""", + {"project_id": project_id, "user_id": user_id, + "tenant_id": tenant_id, "session_id": session_id}) + + rows = cur.fetchall() + rows = helper.list_to_camel_case(rows) + for row in rows: + row["createdAt"] = TimeUTC.datetime_to_timestamp(row["createdAt"]) + return rows + + +def get_all_notes(tenant_id, project_id, user_id): + with pg_client.PostgresClient() as cur: + query = cur.mogrify(f"""SELECT sessions_notes.* + FROM sessions_notes + INNER JOIN users USING (user_id) + WHERE sessions_notes.project_id = %(project_id)s + AND sessions_notes.deleted_at IS NULL + AND (sessions_notes.user_id = %(user_id)s + OR sessions_notes.is_public AND users.tenant_id = %(tenant_id)s) + ORDER BY created_at DESC;""", + {"project_id": project_id, "user_id": user_id, "tenant_id": tenant_id}) + + cur.execute(query=query) + rows = cur.fetchall() + rows = helper.list_to_camel_case(rows) + for row in rows: + row["createdAt"] = TimeUTC.datetime_to_timestamp(row["createdAt"]) + return rows + + +def create(tenant_id, user_id, project_id, session_id, data: schemas.SessionNoteSchema): + with pg_client.PostgresClient() as cur: + query = cur.mogrify(f"""INSERT INTO public.sessions_notes (message, user_id, tags, session_id, project_id, timestamp, is_public) + VALUES (%(message)s, %(user_id)s, %(tags)s, %(session_id)s, %(project_id)s, %(timestamp)s, %(is_public)s) + RETURNING *;""", + {"user_id": user_id, "project_id": project_id, "session_id": session_id, **data.dict()}) + cur.execute(query) + result = cur.fetchone() + return helper.dict_to_camel_case(result) + + +def edit(tenant_id, user_id, project_id, note_id, data: schemas.SessionUpdateNoteSchema): + sub_query = [] + if data.message is not None: + sub_query.append("message = %(message)s") + if data.tags is not None: + sub_query.append("tags = %(tags)s") + if data.is_public is not None: + sub_query.append("is_public = %(is_public)s") + if data.timestamp is not None: + sub_query.append("timestamp = %(timestamp)s") + with pg_client.PostgresClient() as cur: + cur.execute( + cur.mogrify(f"""\ + UPDATE public.sessions_notes + SET + {" ,".join(sub_query)} + WHERE + project_id = %(project_id)s + AND user_id = %(user_id)s + AND note_id = %(note_id)s + AND deleted_at ISNULL + RETURNING *;""", + {"project_id": project_id, "user_id": user_id, "note_id": note_id, **data.dict()}) + ) + row = helper.dict_to_camel_case(cur.fetchone()) + if row: + row["createdAt"] = TimeUTC.datetime_to_timestamp(row["createdAt"]) + return row + + +def delete(tenant_id, user_id, project_id, note_id): + with pg_client.PostgresClient() as cur: + cur.execute( + cur.mogrify("""\ + UPDATE public.sessions_notes + SET + deleted_at = timezone('utc'::text, now()) + WHERE + note_id = %(note_id)s + AND project_id = %(project_id)s\ + AND user_id = %(user_id)s + AND deleted_at ISNULL;""", + {"project_id": project_id, "user_id": user_id, "note_id": note_id}) + ) + return {"data": {"state": "success"}} diff --git a/api/routers/core_dynamic.py b/api/routers/core_dynamic.py index a9b50b4dc..d2357b319 100644 --- a/api/routers/core_dynamic.py +++ b/api/routers/core_dynamic.py @@ -6,7 +6,7 @@ from starlette.responses import RedirectResponse, FileResponse import schemas from chalicelib.core import sessions, errors, errors_viewed, errors_favorite, sessions_assignments, heatmaps, \ - sessions_favorite, assist + sessions_favorite, assist, sessions_notes from chalicelib.core import sessions_viewed from chalicelib.core import tenants, users, projects, license from chalicelib.core import webhook @@ -372,3 +372,57 @@ def comment_assignment(projectId: int, sessionId: int, issueId: str, data: schem return { 'data': data } + + +@app.post('/{projectId}/sessions/{sessionId}/notes', tags=["sessions", "notes"]) +@app.put('/{projectId}/sessions/{sessionId}/notes', tags=["sessions", "notes"]) +def create_note(projectId: int, sessionId: int, data: schemas.SessionNoteSchema = Body(...), + context: schemas.CurrentContext = Depends(OR_context)): + data = sessions_notes.create(tenant_id=context.tenant_id, project_id=projectId, + session_id=sessionId, user_id=context.user_id, data=data) + if "errors" in data.keys(): + return data + return { + 'data': data + } + + +@app.get('/{projectId}/sessions/{sessionId}/notes', tags=["sessions", "notes"]) +def get_session_notes(projectId: int, sessionId: int, context: schemas.CurrentContext = Depends(OR_context)): + data = sessions_notes.get_session_notes(tenant_id=context.tenant_id, project_id=projectId, + session_id=sessionId, user_id=context.user_id) + if "errors" in data: + return data + return { + 'data': data + } + + +@app.post('/{projectId}/notes/{noteId}', tags=["sessions", "notes"]) +@app.put('/{projectId}/notes/{noteId}', tags=["sessions", "notes"]) +def edit_note(projectId: int, noteId: int, data: schemas.SessionUpdateNoteSchema = Body(...), + context: schemas.CurrentContext = Depends(OR_context)): + data = sessions_notes.edit(tenant_id=context.tenant_id, project_id=projectId, user_id=context.user_id, + note_id=noteId, data=data) + if "errors" in data.keys(): + return data + return { + 'data': data + } + + +@app.delete('/{projectId}/notes/{noteId}', tags=["sessions", "notes"]) +def delete_note(projectId: int, noteId: int, context: schemas.CurrentContext = Depends(OR_context)): + data = sessions_notes.delete(tenant_id=context.tenant_id, project_id=projectId, user_id=context.user_id, + note_id=noteId) + return data + + +@app.get('/{projectId}/notes', tags=["sessions", "notes"]) +def get_all_notes(projectId: int, context: schemas.CurrentContext = Depends(OR_context)): + data = sessions_notes.get_all_notes(tenant_id=context.tenant_id, project_id=projectId, user_id=context.user_id) + if "errors" in data: + return data + return { + 'data': data + } diff --git a/api/schemas.py b/api/schemas.py index f6dc8b34b..9be29e84a 100644 --- a/api/schemas.py +++ b/api/schemas.py @@ -1084,3 +1084,31 @@ class IntegrationType(str, Enum): stackdriver = "STACKDRIVER" cloudwatch = "CLOUDWATCH" newrelic = "NEWRELIC" + + +class SessionNoteSchema(BaseModel): + message: str = Field(..., min_length=2) + tags: List[str] = Field(default=[]) + timestamp: int = Field(default=-1) + is_public: bool = Field(default=False) + + class Config: + alias_generator = attribute_to_camel_case + + +class SessionUpdateNoteSchema(SessionNoteSchema): + message: Optional[str] = Field(default=None, min_length=2) + tags: Optional[List[str]] = Field(default=None) + timestamp: Optional[int] = Field(default=None, ge=-1) + is_public: Optional[bool] = Field(default=None) + + @root_validator + def validator(cls, values): + assert len(values.keys()) > 0, "at least 1 attribute should be provided for update" + c = 0 + for v in values.values(): + if v is not None and (not isinstance(v, str) or len(v) > 0): + c += 1 + break + assert c > 0, "at least 1 value should be provided for update" + return values diff --git a/ee/api/.gitignore b/ee/api/.gitignore index 811b00301..924060617 100644 --- a/ee/api/.gitignore +++ b/ee/api/.gitignore @@ -213,6 +213,7 @@ Pipfile /chalicelib/core/sessions_assignments.py /chalicelib/core/sessions_metas.py /chalicelib/core/sessions_mobs.py +/chalicelib/core/sessions_notes.py #exp /chalicelib/core/significance.py /chalicelib/core/slack.py /chalicelib/core/socket_ios.py diff --git a/ee/api/clean.sh b/ee/api/clean.sh index ce58fe45e..53607cb25 100755 --- a/ee/api/clean.sh +++ b/ee/api/clean.sh @@ -35,6 +35,7 @@ rm -rf ./chalicelib/core/mobile.py rm -rf ./chalicelib/core/sessions_assignments.py rm -rf ./chalicelib/core/sessions_metas.py rm -rf ./chalicelib/core/sessions_mobs.py +rm -rf ./chalicelib/core/sessions_notes.py #exp rm -rf ./chalicelib/core/significance.py rm -rf ./chalicelib/core/slack.py rm -rf ./chalicelib/core/socket_ios.py diff --git a/ee/api/routers/core_dynamic.py b/ee/api/routers/core_dynamic.py index ed31fd56c..ee3c3a83f 100644 --- a/ee/api/routers/core_dynamic.py +++ b/ee/api/routers/core_dynamic.py @@ -7,7 +7,7 @@ from starlette.responses import RedirectResponse, FileResponse import schemas import schemas_ee from chalicelib.core import sessions, assist, heatmaps, sessions_favorite, sessions_assignments, errors, errors_viewed, \ - errors_favorite + errors_favorite, sessions_notes from chalicelib.core import sessions_viewed from chalicelib.core import tenants, users, projects, license from chalicelib.core import webhook @@ -396,3 +396,62 @@ def comment_assignment(projectId: int, sessionId: int, issueId: str, data: schem return { 'data': data } + + +@app.post('/{projectId}/sessions/{sessionId}/notes', tags=["sessions", "notes"], + dependencies=[OR_scope(Permissions.session_replay)]) +@app.put('/{projectId}/sessions/{sessionId}/notes', tags=["sessions", "notes"], + dependencies=[OR_scope(Permissions.session_replay)]) +def create_note(projectId: int, sessionId: int, data: schemas.SessionNoteSchema = Body(...), + context: schemas.CurrentContext = Depends(OR_context)): + data = sessions_notes.create(tenant_id=context.tenant_id, project_id=projectId, + session_id=sessionId, user_id=context.user_id, data=data) + if "errors" in data.keys(): + return data + return { + 'data': data + } + + +@app.get('/{projectId}/sessions/{sessionId}/notes', tags=["sessions", "notes"], + dependencies=[OR_scope(Permissions.session_replay)]) +def get_session_notes(projectId: int, sessionId: int, context: schemas.CurrentContext = Depends(OR_context)): + data = sessions_notes.get_session_notes(tenant_id=context.tenant_id, project_id=projectId, + session_id=sessionId, user_id=context.user_id) + if "errors" in data: + return data + return { + 'data': data + } + + +@app.post('/{projectId}/notes/{noteId}', tags=["sessions", "notes"], + dependencies=[OR_scope(Permissions.session_replay)]) +@app.put('/{projectId}/notes/{noteId}', tags=["sessions", "notes"], dependencies=[OR_scope(Permissions.session_replay)]) +def edit_note(projectId: int, noteId: int, data: schemas.SessionUpdateNoteSchema = Body(...), + context: schemas.CurrentContext = Depends(OR_context)): + data = sessions_notes.edit(tenant_id=context.tenant_id, project_id=projectId, user_id=context.user_id, + note_id=noteId, data=data) + if "errors" in data.keys(): + return data + return { + 'data': data + } + + +@app.delete('/{projectId}/notes/{noteId}', tags=["sessions", "notes"], + dependencies=[OR_scope(Permissions.session_replay)]) +def delete_note(projectId: int, noteId: int, context: schemas.CurrentContext = Depends(OR_context)): + data = sessions_notes.delete(tenant_id=context.tenant_id, project_id=projectId, user_id=context.user_id, + note_id=noteId) + return data + + +@app.get('/{projectId}/notes', tags=["sessions", "notes"], dependencies=[OR_scope(Permissions.session_replay)]) +def get_all_notes(projectId: int, context: schemas.CurrentContext = Depends(OR_context)): + data = sessions_notes.get_all_notes(tenant_id=context.tenant_id, project_id=projectId, user_id=context.user_id) + if "errors" in data: + return data + return { + 'data': data + }