Skip to content

Commit

Permalink
Merge pull request #91 from MathisBurger/feature/code-comments
Browse files Browse the repository at this point in the history
Code Comments Feature
  • Loading branch information
MathisBurger authored Oct 30, 2024
2 parents 51f2d28 + 0cf1f92 commit 82d7dd1
Show file tree
Hide file tree
Showing 14 changed files with 320 additions and 2 deletions.
1 change: 1 addition & 0 deletions tasky/migrations/2024-10-29-191834_code_comments/down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DROP TABLE code_comments;
8 changes: 8 additions & 0 deletions tasky/migrations/2024-10-29-191834_code_comments/up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
CREATE TABLE code_comments (
id SERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
commentor INTEGER NOT NULL,
group_id INTEGER NOT NULL REFERENCES groups(id),
solution_id INTEGER NOT NULL REFERENCES solutions(id)
);
51 changes: 51 additions & 0 deletions tasky/src/models/code_comment.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
use super::DB;
use crate::schema::code_comments::dsl;
use diesel::associations::HasTable;
use diesel::prelude::*;
use diesel::Selectable;
use serde::Serialize;

/// code comment entity type
#[derive(Queryable, Selectable, Clone, Serialize)]
#[diesel(table_name = crate::schema::code_comments)]
#[diesel(check_for_backend(diesel::pg::Pg))]
pub struct CodeComment {
pub id: i32,
pub title: String,
pub content: String,
pub commentor: i32,
pub group_id: i32,
pub solution_id: i32,
}

/// Create comment struct to create a code comment
#[derive(Insertable)]
#[diesel(table_name = crate::schema::code_comments)]
pub struct CreateCodeComment {
pub title: String,
pub content: String,
pub commentor: i32,
pub group_id: i32,
pub solution_id: i32,
}

pub struct CodeCommentRepository;

impl CodeCommentRepository {
/// Creates a new code comment
pub fn create_comment(create: &CreateCodeComment, conn: &mut DB) -> CodeComment {
diesel::insert_into(dsl::code_comments::table())
.values(create)
.returning(CodeComment::as_returning())
.get_result::<CodeComment>(conn)
.expect("Cannot create new code comment")
}

/// Gets all comments for solution
pub fn get_comments_for_solution(solution_id: i32, conn: &mut DB) -> Vec<CodeComment> {
dsl::code_comments
.filter(dsl::solution_id.eq(solution_id))
.get_results::<CodeComment>(conn)
.expect("Cannot fetch comments")
}
}
1 change: 1 addition & 0 deletions tasky/src/models/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use diesel::r2d2::{ConnectionManager, PooledConnection};

pub mod assignment;
pub mod assignment_wish;
pub mod code_comment;
pub mod database;
pub mod group;
pub mod group_join_request;
Expand Down
84 changes: 84 additions & 0 deletions tasky/src/routes/code_comment.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
use crate::auth_middleware::UserData;
use crate::models::solution::SolutionRepository;
use crate::models::DB;
use crate::security::SecurityAction;
use crate::AppState;
use crate::{
error::ApiError,
models::{
code_comment::{CodeCommentRepository, CreateCodeComment},
solution::Solution,
},
security::IsGranted,
};
use actix_web::{get, post, web, HttpResponse};
use serde::Deserialize;

#[derive(Deserialize)]
pub struct CodeCommentRequest {
pub title: String,
pub content: String,
}

/// Gets all code comments for a solution
#[get("/solutions/{solution_id}/code_comments")]
pub async fn get_code_comments(
data: web::Data<AppState>,
user: web::ReqData<UserData>,
path: web::Path<(i32,)>,
) -> Result<HttpResponse, ApiError> {
let user_data = user.into_inner();
let path_data = path.into_inner();
let conn = &mut data.db.db.get().unwrap();

let solution = get_solution(path_data.0, &user_data, conn)?;
let comments = CodeCommentRepository::get_comments_for_solution(solution.id, conn);
Ok(HttpResponse::Ok().json(comments))
}

/// Creates a code comment on a solution
#[post("/solutions/{solution_id}/code_comments")]
pub async fn create_code_comment(
data: web::Data<AppState>,
user: web::ReqData<UserData>,
req: web::Json<CodeCommentRequest>,
path: web::Path<(i32,)>,
) -> Result<HttpResponse, ApiError> {
let user_data = user.into_inner();
let path_data = path.into_inner();
let conn = &mut data.db.db.get().unwrap();

let solution = get_solution(path_data.0, &user_data, conn)?;
let mut create = CreateCodeComment {
title: req.title.clone(),
content: req.content.clone(),
commentor: user_data.user_id,
group_id: solution.group_id.unwrap_or(-1),
solution_id: solution.id,
};
if !create.is_granted(SecurityAction::Create, &user_data) {
return Err(ApiError::Forbidden {
message: "You are not allowed to create a code comment".to_string(),
});
}
let comment = CodeCommentRepository::create_comment(&create, conn);
Ok(HttpResponse::Ok().json(comment))
}

/// Gets solution and checks basic read permissions
fn get_solution(
solution_id: i32,
user_data: &UserData,
conn: &mut DB,
) -> Result<Solution, ApiError> {
let mut solution =
SolutionRepository::get_solution_by_id(solution_id, conn).ok_or(ApiError::BadRequest {
message: "Invalid solution".to_string(),
})?;
if !solution.is_granted(SecurityAction::Read, user_data) {
return Err(ApiError::Forbidden {
message: "You have no access to solution".to_string(),
});
}
Ok(solution)
}
5 changes: 4 additions & 1 deletion tasky/src/routes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use actix_web::web;

pub mod assignment;
pub mod assignment_wish;
pub mod code_comment;
pub mod group;
pub mod group_join_request;
pub mod solution;
Expand Down Expand Up @@ -33,5 +34,7 @@ pub fn init_services(cfg: &mut web::ServiceConfig) {
.service(assignment_wish::create_wish)
.service(assignment_wish::get_wishes)
.service(assignment_wish::get_wish)
.service(assignment_wish::delete_wish);
.service(assignment_wish::delete_wish)
.service(code_comment::get_code_comments)
.service(code_comment::create_code_comment);
}
15 changes: 15 additions & 0 deletions tasky/src/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,18 @@ diesel::table! {
}
}

diesel::table! {
code_comments (id) {
id -> Int4,
#[max_length = 255]
title -> Varchar,
content -> Text,
commentor -> Int4,
group_id -> Int4,
solution_id -> Int4,
}
}

diesel::table! {
group_join_requests (id) {
id -> Int4,
Expand Down Expand Up @@ -75,13 +87,16 @@ diesel::table! {

diesel::joinable!(assignment_wishes -> groups (group_id));
diesel::joinable!(assignments -> groups (group_id));
diesel::joinable!(code_comments -> groups (group_id));
diesel::joinable!(code_comments -> solutions (solution_id));
diesel::joinable!(group_join_requests -> groups (group_id));
diesel::joinable!(solutions -> assignments (assignment_id));
diesel::joinable!(solutions -> groups (group_id));

diesel::allow_tables_to_appear_in_same_query!(
assignment_wishes,
assignments,
code_comments,
group_join_requests,
groups,
solutions,
Expand Down
18 changes: 18 additions & 0 deletions tasky/src/security/code_comment.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
use crate::models::code_comment::CreateCodeComment;

use super::{IsGranted, SecurityAction, StaticSecurity};

impl IsGranted for CreateCodeComment {
fn is_granted(
&mut self,
action: super::SecurityAction,
user: &crate::auth_middleware::UserData,
) -> bool {
if action == SecurityAction::Create {
return StaticSecurity::is_granted(super::StaticSecurityAction::IsAdmin, user)
|| (StaticSecurity::is_granted(super::StaticSecurityAction::IsTutor, user)
&& user.groups.contains(&self.group_id));
}
false
}
}
1 change: 1 addition & 0 deletions tasky/src/security/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use crate::auth_middleware::{UserData, UserRole};

pub mod assignment;
pub mod assignment_wish;
pub mod code_comment;
pub mod group;
pub mod group_join_request;
pub mod solution;
Expand Down
5 changes: 5 additions & 0 deletions web/app/solutions/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import NavigateBack from "@/components/NavigateBack";
import FileStructureDisplay from "@/components/FileStructureDisplay";
import QuestionAnswersDisplay from "@/components/solution/questions/QuestionAnswersDisplay";
import {useSpotlightStage2} from "@/hooks/spotlight/stage2";
import CommentTab from "@/components/solution/CommentTab";

// Every 30s
const REFETCH_INTERVAL = 1000 * 30;
Expand Down Expand Up @@ -120,6 +121,7 @@ const SolutionDetailsPage = ({ params }: { params: { id: string } }) => {
<Tabs.Tab value="code">Code</Tabs.Tab>
</>
)}
<Tabs.Tab value="comments">Comments</Tabs.Tab>
</Tabs.List>

{solution.assignment.language === AssignmentLanguage.QuestionBased ? (
Expand All @@ -144,6 +146,9 @@ const SolutionDetailsPage = ({ params }: { params: { id: string } }) => {
</Tabs.Panel>
</>
)}
<Tabs.Panel value="comments" mt={10}>
<CommentTab solution={solution} />
</Tabs.Panel>
</Tabs>
{executorModalOpen &&
solution.job !== undefined &&
Expand Down
53 changes: 53 additions & 0 deletions web/components/solution/CommentTab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import useApiServiceClient from "@/hooks/useApiServiceClient";
import useClientQuery from "@/hooks/useClientQuery";
import { Solution } from "@/service/types/tasky";
import {Badge, Button, Card, Group, Stack, Title} from "@mantine/core";
import AssignmentDateDisplay from "@/components/assignments/AssignmentDateDisplay";
import RichTextDisplay from "@/components/display/RichTextDisplay";
import useCurrentUser from "@/hooks/useCurrentUser";
import {IconPlus} from "@tabler/icons-react";
import {useState} from "react";
import CreateCommentModal from "@/components/solution/CreateCommentModal";

interface CommentTabProps {
solution: Solution;
}

const CommentTab = ({solution}: CommentTabProps) => {

const api = useApiServiceClient();
const {user} = useCurrentUser();
const [createModalOpen, setCreateModalOpen] = useState(false);
const [comments, refetch] = useClientQuery(() => api.getCodeComments(solution.id));

return (
<>
<Stack gap={10}>
<Group justify="flex-end">
<Button onClick={() => setCreateModalOpen(true)}><IconPlus />
&nbsp;Create Comment</Button>
</Group>
{(comments ?? []).map((comment) => (
<Card shadow="sm" padding="lg" radius="md" withBorder key={comment.id}>
<Group>
<Title order={4}>{comment.title}</Title>
{comment.commentor === user?.id && (
<Badge color="green">Your comment</Badge>
)}
</Group>
<RichTextDisplay content={comment.content} fullSize={false} />
</Card>
))}
</Stack>
{createModalOpen && (
<CreateCommentModal
solution={solution}
refetch={refetch}
onClose={() => setCreateModalOpen(false)}
/>
)}
</>
);
}

export default CommentTab;
63 changes: 63 additions & 0 deletions web/components/solution/CreateCommentModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { Solution } from "@/service/types/tasky";
import {Button, Divider, Group, Modal, Stack, TextInput} from "@mantine/core";
import {useForm} from "@mantine/form";
import RichTextInput from "@/components/form/RichTextInput";
import useApiServiceClient from "@/hooks/useApiServiceClient";
import {notifications} from "@mantine/notifications";


interface CreateCommentModalProps {
solution: Solution;
refetch: () => void;
onClose: () => void;
}

const CreateCommentModal = ({solution, refetch, onClose}: CreateCommentModalProps) => {

const form = useForm({
initialValues: {
title: '',
content: ''
}
});
const api = useApiServiceClient();

const onSubmit = form.onSubmit(async (values) => {
try {
await api.createCodeComment(solution.id, values.title, values.content);
refetch();
onClose();
} catch (e: any) {
notifications.show({
title: 'Error',
message: e?.message ?? "Failed to create comment",
})
}
});

return (
<Modal opened onClose={onClose} title="Create code comment" size="xl">
<form onSubmit={onSubmit}>
<Stack gap={10}>
<TextInput label="Title" key={form.key('title')} {...form.getInputProps('title')} />
<RichTextInput
key={form.key('content')}
content={form.getInputProps('content').value}
setContent={form.getInputProps('content').onChange}
/>
</Stack>
<Divider mt={10} />
<Group mt={10}>
<Button type="submit">
Create questions
</Button>
<Button onClick={onClose} color="gray">
Cancel
</Button>
</Group>
</form>
</Modal>
);
}

export default CreateCommentModal;
Loading

0 comments on commit 82d7dd1

Please sign in to comment.