Skip to content

Commit 6c48470

Browse files
authored
Merge pull request #39 from compsoc-edinburgh/add-anonymous-answers
Anonymous Answers
2 parents f7346bf + d6d806f commit 6c48470

File tree

9 files changed

+217
-81
lines changed

9 files changed

+217
-81
lines changed
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 4.1.13 on 2025-06-03 12:24
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('answers', '0016_remove_answer_is_legacy_answer_and_more'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='answer',
15+
name='is_anonymous',
16+
field=models.BooleanField(default=False),
17+
),
18+
]

backend/answers/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ class Answer(ExportModelOperationsMixin("answer"), models.Model):
137137
)
138138
flagged = models.ManyToManyField("auth.User", related_name="flagged_answer_set")
139139
long_id = models.CharField(max_length=256, default=generate_long_id, unique=True)
140+
is_anonymous = models.BooleanField(default=False)
140141

141142
search_vector = SearchVectorField()
142143

backend/answers/section_util.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,13 +64,31 @@ def get_answer_response(request, answer: Answer, ignore_exam_admin=False):
6464
}
6565
for comment in answer.all_comments
6666
]
67+
68+
# Handle anonymous answers
69+
# Check if user is an admin (either exam admin or global admin)
70+
is_admin = (ignore_exam_admin is False and exam_admin) or auth_check.has_admin_rights(request)
71+
72+
if answer.is_anonymous:
73+
if is_admin:
74+
# Admins see the real username but with an indication it's anonymous
75+
author_id = answer.author.username
76+
author_display_name = answer.author.profile.display_username
77+
else:
78+
# Regular users see "Anonymous"
79+
author_id = "anonymous"
80+
author_display_name = "Anonymous"
81+
else:
82+
author_id = answer.author.username
83+
author_display_name = answer.author.profile.display_username
84+
6785
return {
6886
"oid": answer.id,
6987
"longId": answer.long_id,
7088
"upvotes": answer.delta_votes,
7189
"expertvotes": answer.expert_count,
72-
"authorId": answer.author.username,
73-
"authorDisplayName": answer.author.profile.display_username,
90+
"authorId": author_id,
91+
"authorDisplayName": author_display_name,
7492
"canEdit": answer.author == request.user,
7593
"isUpvoted": answer.is_upvoted,
7694
"isDownvoted": answer.is_downvoted,
@@ -83,6 +101,7 @@ def get_answer_response(request, answer: Answer, ignore_exam_admin=False):
83101
"edittime": answer.edittime,
84102
"filename": answer.answer_section.exam.filename,
85103
"sectionId": answer.answer_section.id,
104+
"isAnonymous": answer.is_anonymous,
86105
}
87106
except AttributeError:
88107
raise ValueError(

backend/answers/views_answers.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ def get_answer(request, long_id):
1919
raise Http404()
2020

2121

22-
@response.request_post("text")
22+
@response.request_post("text", "is_anonymous", optional=True)
2323
@auth_check.require_login
2424
def set_answer(request, oid):
2525
section = get_object_or_404(
@@ -38,6 +38,7 @@ def set_answer(request, oid):
3838
return response.not_allowed()
3939

4040
text = request.POST["text"]
41+
is_anonymous = request.POST.get("is_anonymous", "false") != "false"
4142

4243
where = {"answer_section": section, "author": request.user}
4344

@@ -49,6 +50,7 @@ def set_answer(request, oid):
4950
"author": request.user,
5051
"text": text,
5152
"edittime": timezone.now(),
53+
"is_anonymous": is_anonymous,
5254
}
5355
answer, created = Answer.objects.update_or_create(**where, defaults=defaults)
5456
if created:

backend/answers/views_listings.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,18 @@ def list_flagged(request):
7676
@response.request_get()
7777
@auth_check.require_login
7878
def get_by_user(request, username, page=-1):
79-
sorted_answers = Answer.objects.filter(author__username=username).select_related(
79+
# Check if the user is viewing their own profile or is an admin
80+
is_own_profile = request.user.username == username
81+
is_admin = has_admin_rights(request)
82+
83+
# Base query to get answers by the user
84+
query = Answer.objects.filter(author__username=username)
85+
86+
# If not viewing own profile and not an admin, exclude anonymous answers
87+
if not is_own_profile and not is_admin:
88+
query = query.filter(is_anonymous=False)
89+
90+
sorted_answers = query.select_related(
8091
*section_util.get_answer_fields_to_preselect()
8192
)
8293

frontend/src/api/hooks.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -271,8 +271,8 @@ export const useRemoveSplit = (oid: string, onSuccess: () => void) => {
271271
return runRemoveSplit;
272272
};
273273

274-
const updateAnswer = async (answerId: string, text: string) => {
275-
return (await fetchPost(`/api/exam/setanswer/${answerId}/`, { text }))
274+
const updateAnswer = async (answerId: string, text: string, isAnonymous: boolean = false) => {
275+
return (await fetchPost(`/api/exam/setanswer/${answerId}/`, { text, is_anonymous: isAnonymous }))
276276
.value as AnswerSection;
277277
};
278278
const removeAnswer = async (answerId: string) => {

frontend/src/components/answer.tsx

Lines changed: 130 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import {
99
Anchor,
1010
Box,
1111
Paper,
12+
Switch,
13+
Tooltip,
1214
} from "@mantine/core";
1315
import { differenceInSeconds } from "date-fns";
1416
import React, { useCallback, useState } from "react";
@@ -23,6 +25,8 @@ import {
2325
} from "../api/hooks";
2426
import { useUser } from "../auth";
2527
import useConfirm from "../hooks/useConfirm";
28+
import useToggle from "../hooks/useToggle";
29+
2630
import { Answer, AnswerSection } from "../interfaces";
2731
import { copy } from "../utils/clipboard";
2832
import CodeBlock from "./code-block";
@@ -88,27 +92,33 @@ const AnswerComponent: React.FC<Props> = ({
8892

8993
const [draftText, setDraftText] = useState("");
9094
const [undoStack, setUndoStack] = useState<UndoStack>({ prev: [], next: [] });
95+
const [answerIsAnonymous, toggleAnonymity] = useToggle(false);
96+
const [hasCommentDraft, setHasCommentDraft] = useState(false);
97+
9198
const startEdit = useCallback(() => {
9299
setDraftText(answer?.text ?? "");
100+
if (answer?.isAnonymous) {
101+
toggleAnonymity(true);
102+
}
93103
setEditing(true);
94-
}, [answer]);
104+
}, [answer, toggleAnonymity]);
95105
const onCancel = useCallback(() => {
96106
setEditing(false);
97107
if (answer === undefined && onDelete) onDelete();
98108
}, [onDelete, answer]);
99109
const save = useCallback(() => {
100-
if (section) update(section.oid, draftText);
101-
}, [section, draftText, update]);
110+
if (section) update(section.oid, draftText, answerIsAnonymous);
111+
}, [section, draftText, update, answerIsAnonymous]);
102112
const remove = useCallback(() => {
103113
if (answer) confirm("Remove answer?", () => removeAnswer(answer.oid));
104114
}, [confirm, removeAnswer, answer]);
105-
const [hasCommentDraft, setHasCommentDraft] = useState(false);
106115

107116
const flaggedLoading = setFlaggedLoading || resetFlaggedLoading;
108117
const canEdit = section && onSectionChanged && (answer?.canEdit || false);
109118
const canRemove =
110119
section && onSectionChanged && (isAdmin || answer?.canEdit || false);
111120
const { username } = useUser()!;
121+
112122
return (
113123
<>
114124
{modals}
@@ -131,18 +141,44 @@ const AnswerComponent: React.FC<Props> = ({
131141
</Text>
132142
</Link>
133143
)}
134-
<Anchor
135-
component={Link}
136-
to={`/user/${answer?.authorId ?? username}`}
137-
className={displayNameClasses.shrinkableDisplayName}
138-
>
139-
<Text fw={700} component="span">
140-
{answer?.authorDisplayName ?? "(Draft)"}
141-
</Text>
142-
<Text ml="0.3em" c="dimmed" component="span">
143-
@{answer?.authorId ?? username}
144-
</Text>
145-
</Anchor>
144+
{answer?.isAnonymous ? (
145+
isAdmin ? (
146+
// Admin view of anonymous posts - clickable
147+
<Anchor
148+
component={Link}
149+
to={`/user/${answer.authorId}`}
150+
className={displayNameClasses.shrinkableDisplayName}
151+
>
152+
<Text fw={700} component="span">
153+
{answer.authorDisplayName} <Text c="dimmed" component="span">(Posted anonymously)</Text>
154+
</Text>
155+
</Anchor>
156+
) : answer?.canEdit ? (
157+
// User's own anonymous post
158+
<Text fw={700} component="span">
159+
{answer.authorDisplayName} <Text c="dimmed" component="span">(Your anonymous post)</Text>
160+
</Text>
161+
) : (
162+
// Regular user view of anonymous posts - not clickable
163+
<Text fw={700} component="span">
164+
{answer.authorDisplayName}
165+
</Text>
166+
)
167+
) : (
168+
// Regular non-anonymous posts
169+
<Anchor
170+
component={Link}
171+
to={`/user/${answer?.authorId ?? username}`}
172+
className={displayNameClasses.shrinkableDisplayName}
173+
>
174+
<Text fw={700} component="span">
175+
{answer?.authorDisplayName ?? "(Draft)"}
176+
</Text>
177+
<Text ml="0.3em" c="dimmed" component="span">
178+
@{answer?.authorId ?? username}
179+
</Text>
180+
</Anchor>
181+
)}
146182
<Text c="dimmed" mx={6} component="span">
147183
·
148184
</Text>
@@ -297,6 +333,26 @@ const AnswerComponent: React.FC<Props> = ({
297333
</Card.Section>
298334
)}
299335
<Group justify="right">
336+
{(answer === undefined || editing) && (
337+
<Tooltip
338+
label="Your answer will appear as 'Anonymous' to regular users, but admins will still be able to see your username. Anonymous answers won't appear on your public profile."
339+
multiline
340+
maw={300}
341+
withArrow
342+
withinPortal
343+
>
344+
<div>
345+
<Switch
346+
label={answer?.isAnonymous ? "Post Anonymously (currently anonymous)" : "Post Anonymously"}
347+
onChange={() => {
348+
console.log("Toggling anonymity");
349+
toggleAnonymity();
350+
}}
351+
checked={answerIsAnonymous}
352+
/>
353+
</div>
354+
</Tooltip>
355+
)}
300356
{(answer === undefined || editing) && (
301357
<Button
302358
size="sm"
@@ -322,69 +378,69 @@ const AnswerComponent: React.FC<Props> = ({
322378
{onSectionChanged && !editing && (
323379
<Flex align="center">
324380
{answer !== undefined && (
325-
<Button.Group>
326-
<Button
327-
size="sm"
328-
onClick={() => setHasCommentDraft(true)}
329-
leftSection={<IconPlus />}
330-
disabled={hasCommentDraft}
331-
color="dark"
332-
>
333-
Add Comment
334-
</Button>
335-
<Menu withinPortal>
336-
<Menu.Target>
337-
<Button leftSection={<IconDots />} color="dark">More</Button>
338-
</Menu.Target>
339-
<Menu.Dropdown>
340-
{answer.flagged === 0 && (
341-
<Menu.Item
342-
leftSection={<IconFlag />}
343-
onClick={() => setFlagged(answer.oid, true)}
344-
>
345-
Flag as Inappropriate
346-
</Menu.Item>
347-
)}
348-
<Menu.Item
349-
leftSection={<IconLink />}
350-
onClick={() =>
351-
copy(
352-
`${document.location.origin}/exams/${answer.filename}#${answer.longId}`,
353-
)
354-
}
355-
>
356-
Copy Permalink
357-
</Menu.Item>
358-
{isAdmin && answer.flagged > 0 && (
381+
<Button.Group>
382+
<Button
383+
size="sm"
384+
onClick={() => setHasCommentDraft(true)}
385+
leftSection={<IconPlus />}
386+
disabled={hasCommentDraft}
387+
color="dark"
388+
>
389+
Add Comment
390+
</Button>
391+
<Menu withinPortal>
392+
<Menu.Target>
393+
<Button leftSection={<IconDots />} color="dark">More</Button>
394+
</Menu.Target>
395+
<Menu.Dropdown>
396+
{answer.flagged === 0 && (
397+
<Menu.Item
398+
leftSection={<IconFlag />}
399+
onClick={() => setFlagged(answer.oid, true)}
400+
>
401+
Flag as Inappropriate
402+
</Menu.Item>
403+
)}
359404
<Menu.Item
360-
leftSection={<IconFlag />}
361-
onClick={() => resetFlagged(answer.oid)}
405+
leftSection={<IconLink />}
406+
onClick={() =>
407+
copy(
408+
`${document.location.origin}/exams/${answer.filename}#${answer.longId}`,
409+
)
410+
}
362411
>
363-
Remove all inappropriate flags
412+
Copy Permalink
364413
</Menu.Item>
365-
)}
366-
{!editing && canEdit && (
414+
{isAdmin && answer.flagged > 0 && (
415+
<Menu.Item
416+
leftSection={<IconFlag />}
417+
onClick={() => resetFlagged(answer.oid)}
418+
>
419+
Remove all inappropriate flags
420+
</Menu.Item>
421+
)}
422+
{!editing && canEdit && (
423+
<Menu.Item
424+
leftSection={<IconEdit />}
425+
onClick={startEdit}
426+
>
427+
Edit
428+
</Menu.Item>
429+
)}
430+
{answer && canRemove && (
431+
<Menu.Item leftSection={<IconTrash />} onClick={remove}>
432+
Delete
433+
</Menu.Item>
434+
)}
367435
<Menu.Item
368-
leftSection={<IconEdit />}
369-
onClick={startEdit}
436+
leftSection={<IconCode />}
437+
onClick={toggleViewSource}
370438
>
371-
Edit
372-
</Menu.Item>
373-
)}
374-
{answer && canRemove && (
375-
<Menu.Item leftSection={<IconTrash />} onClick={remove}>
376-
Delete
439+
Toggle Source Code Mode
377440
</Menu.Item>
378-
)}
379-
<Menu.Item
380-
leftSection={<IconCode />}
381-
onClick={toggleViewSource}
382-
>
383-
Toggle Source Code Mode
384-
</Menu.Item>
385-
</Menu.Dropdown>
386-
</Menu>
387-
</Button.Group>
441+
</Menu.Dropdown>
442+
</Menu>
443+
</Button.Group>
388444
)}
389445
</Flex>
390446
)}
@@ -405,4 +461,4 @@ const AnswerComponent: React.FC<Props> = ({
405461
);
406462
};
407463

408-
export default AnswerComponent;
464+
export default AnswerComponent;

0 commit comments

Comments
 (0)