Skip to content

Commit

Permalink
Squashed commit of the following:
Browse files Browse the repository at this point in the history
commit 43bf1b3
Author: Liam Keegan <[email protected]>
Date:   Mon Nov 11 08:17:47 2024 +0100

    Add admin option to delete milestone images (#150)

    * Add admin option to delete milestone images

    - add delete button below existing milestone images in admin interface
    - add delete /milestone-images endpoint
    - restate /admin route for convenience during development, can be removed again later
    - resolves #98

    * update openapi.json & openapi-ts client

    ---------

    Co-authored-by: Harald Mack <[email protected]>
    Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

commit 33d4f17
Author: Harald Mack <[email protected]>
Date:   Fri Nov 8 17:23:58 2024 +0100

    Add childquestions backend integration and refactor datainput datastructure (#138)

    * add childanswers and base answer model

    * add child question models

    * add question admin endpoints

    * make current tests work again

    * adjust imports, add test skeleton

    * add dummy questions to test fixtures

    * start working on backend tests

    * work more on backend tests and model inheritance

    * fix errors in test data

    * try to get sqlmodel inheritnace to work

    * get rid of inheritance in question models for now

    * finish user question tests

    * finish python test draft
  • Loading branch information
MaHaWo committed Nov 11, 2024
1 parent d900c19 commit 84c3f17
Show file tree
Hide file tree
Showing 18 changed files with 180 additions and 76 deletions.
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ services:
- ${PRIVATE_FILES_PATH:-./private}:/app/private
environment:
- SECRET=${SECRET:-}
- DATA_FILES_PATH=/app/data
- STATIC_FILES_PATH=/app/static
- PRIVATE_FILES_PATH=/app/private
- DATABASE_PATH=/app/db
- ENABLE_CORS=${ENABLE_CORS:-false}
- HOST=${HOST:-backend}
Expand Down
12 changes: 10 additions & 2 deletions frontend/src/lib/client/services.gen.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// This file is auto-generated by @hey-api/openapi-ts

import { createClient, createConfig, type Options, formDataBodySerializer, urlSearchParamsBodySerializer } from '@hey-api/client-fetch';
import type { GetLanguagesError, GetLanguagesResponse, GetMilestonesError, GetMilestonesResponse, GetMilestoneData, GetMilestoneError, GetMilestoneResponse, GetMilestoneGroupsData, GetMilestoneGroupsError, GetMilestoneGroupsResponse, GetUserQuestionsError, GetUserQuestionsResponse, GetChildQuestionsError, GetChildQuestionsResponse, CreateLanguageData, CreateLanguageError, CreateLanguageResponse, DeleteLanguageData, DeleteLanguageError, DeleteLanguageResponse, UpdateI18NData, UpdateI18NError, UpdateI18NResponse, GetMilestoneGroupsAdminError, GetMilestoneGroupsAdminResponse, CreateMilestoneGroupAdminError, CreateMilestoneGroupAdminResponse, UpdateMilestoneGroupAdminData, UpdateMilestoneGroupAdminError, UpdateMilestoneGroupAdminResponse, DeleteMilestoneGroupAdminData, DeleteMilestoneGroupAdminError, DeleteMilestoneGroupAdminResponse, UploadMilestoneGroupImageData, UploadMilestoneGroupImageError, UploadMilestoneGroupImageResponse, CreateMilestoneData, CreateMilestoneError, CreateMilestoneResponse, UpdateMilestoneData, UpdateMilestoneError, UpdateMilestoneResponse, DeleteMilestoneData, DeleteMilestoneError, DeleteMilestoneResponse, UploadMilestoneImageData, UploadMilestoneImageError, UploadMilestoneImageResponse, GetUserQuestionsAdminError, GetUserQuestionsAdminResponse, UpdateUserQuestionData, UpdateUserQuestionError, UpdateUserQuestionResponse, CreateUserQuestionError, CreateUserQuestionResponse, DeleteUserQuestionData, DeleteUserQuestionError, DeleteUserQuestionResponse, GetChildQuestionsAdminError, GetChildQuestionsAdminResponse, UpdateChildQuestionData, UpdateChildQuestionError, UpdateChildQuestionResponse, CreateChildQuestionError, CreateChildQuestionResponse, DeleteChildQuestionData, DeleteChildQuestionError, DeleteChildQuestionResponse, UsersCurrentUserError, UsersCurrentUserResponse, UsersPatchCurrentUserData, UsersPatchCurrentUserError, UsersPatchCurrentUserResponse, UsersUserData, UsersUserError, UsersUserResponse, UsersPatchUserData, UsersPatchUserError, UsersPatchUserResponse, UsersDeleteUserData, UsersDeleteUserError, UsersDeleteUserResponse, GetChildrenError, GetChildrenResponse, UpdateChildData, UpdateChildError, UpdateChildResponse, CreateChildData, CreateChildError, CreateChildResponse, GetChildData, GetChildError, GetChildResponse, DeleteChildData, DeleteChildError, DeleteChildResponse, GetChildImageData, GetChildImageError, GetChildImageResponse, UploadChildImageData, UploadChildImageError, UploadChildImageResponse, DeleteChildImageData, DeleteChildImageError, DeleteChildImageResponse, GetCurrentMilestoneAnswerSessionData, GetCurrentMilestoneAnswerSessionError, GetCurrentMilestoneAnswerSessionResponse, UpdateMilestoneAnswerData, UpdateMilestoneAnswerError, UpdateMilestoneAnswerResponse, GetCurrentUserAnswersError, GetCurrentUserAnswersResponse, UpdateCurrentUserAnswersData, UpdateCurrentUserAnswersError, UpdateCurrentUserAnswersResponse, GetCurrentChildAnswersData, GetCurrentChildAnswersError, GetCurrentChildAnswersResponse, UpdateCurrentChildAnswersData, UpdateCurrentChildAnswersError, UpdateCurrentChildAnswersResponse, AuthCookieLoginData, AuthCookieLoginError, AuthCookieLoginResponse, AuthCookieLogoutError, AuthCookieLogoutResponse, RegisterRegisterData, RegisterRegisterError, RegisterRegisterResponse, ResetForgotPasswordData, ResetForgotPasswordError, ResetForgotPasswordResponse, ResetResetPasswordData, ResetResetPasswordError, ResetResetPasswordResponse, VerifyRequestTokenData, VerifyRequestTokenError, VerifyRequestTokenResponse, VerifyVerifyData, VerifyVerifyError, VerifyVerifyResponse, AuthError, AuthResponse } from './types.gen';
import { type Options, createClient, createConfig, formDataBodySerializer, urlSearchParamsBodySerializer } from '@hey-api/client-fetch';
import type { AuthCookieLoginData, AuthCookieLoginError, AuthCookieLoginResponse, AuthCookieLogoutError, AuthCookieLogoutResponse, AuthError, AuthResponse, CreateChildData, CreateChildError, CreateChildQuestionError, CreateChildQuestionResponse, CreateChildResponse, CreateLanguageData, CreateLanguageError, CreateLanguageResponse, CreateMilestoneData, CreateMilestoneError, CreateMilestoneGroupAdminError, CreateMilestoneGroupAdminResponse, CreateMilestoneResponse, CreateUserQuestionError, CreateUserQuestionResponse, DeleteChildData, DeleteChildError, DeleteChildImageData, DeleteChildImageError, DeleteChildImageResponse, DeleteChildQuestionData, DeleteChildQuestionError, DeleteChildQuestionResponse, DeleteChildResponse, DeleteLanguageData, DeleteLanguageError, DeleteLanguageResponse, DeleteMilestoneData, DeleteMilestoneError, DeleteMilestoneGroupAdminData, DeleteMilestoneGroupAdminError, DeleteMilestoneGroupAdminResponse, DeleteMilestoneImageData, DeleteMilestoneImageError, DeleteMilestoneImageResponse, DeleteMilestoneResponse, DeleteUserQuestionData, DeleteUserQuestionError, DeleteUserQuestionResponse, GetChildData, GetChildError, GetChildImageData, GetChildImageError, GetChildImageResponse, GetChildQuestionsAdminError, GetChildQuestionsAdminResponse, GetChildQuestionsError, GetChildQuestionsResponse, GetChildResponse, GetChildrenError, GetChildrenResponse, GetCurrentChildAnswersData, GetCurrentChildAnswersError, GetCurrentChildAnswersResponse, GetCurrentMilestoneAnswerSessionData, GetCurrentMilestoneAnswerSessionError, GetCurrentMilestoneAnswerSessionResponse, GetCurrentUserAnswersError, GetCurrentUserAnswersResponse, GetLanguagesError, GetLanguagesResponse, GetMilestoneData, GetMilestoneError, GetMilestoneGroupsAdminError, GetMilestoneGroupsAdminResponse, GetMilestoneGroupsData, GetMilestoneGroupsError, GetMilestoneGroupsResponse, GetMilestoneResponse, GetMilestonesError, GetMilestonesResponse, GetUserQuestionsAdminError, GetUserQuestionsAdminResponse, GetUserQuestionsError, GetUserQuestionsResponse, RegisterRegisterData, RegisterRegisterError, RegisterRegisterResponse, ResetForgotPasswordData, ResetForgotPasswordError, ResetForgotPasswordResponse, ResetResetPasswordData, ResetResetPasswordError, ResetResetPasswordResponse, UpdateChildData, UpdateChildError, UpdateChildQuestionData, UpdateChildQuestionError, UpdateChildQuestionResponse, UpdateChildResponse, UpdateCurrentChildAnswersData, UpdateCurrentChildAnswersError, UpdateCurrentChildAnswersResponse, UpdateCurrentUserAnswersData, UpdateCurrentUserAnswersError, UpdateCurrentUserAnswersResponse, UpdateI18NData, UpdateI18NError, UpdateI18NResponse, UpdateMilestoneAnswerData, UpdateMilestoneAnswerError, UpdateMilestoneAnswerResponse, UpdateMilestoneData, UpdateMilestoneError, UpdateMilestoneGroupAdminData, UpdateMilestoneGroupAdminError, UpdateMilestoneGroupAdminResponse, UpdateMilestoneResponse, UpdateUserQuestionData, UpdateUserQuestionError, UpdateUserQuestionResponse, UploadChildImageData, UploadChildImageError, UploadChildImageResponse, UploadMilestoneGroupImageData, UploadMilestoneGroupImageError, UploadMilestoneGroupImageResponse, UploadMilestoneImageData, UploadMilestoneImageError, UploadMilestoneImageResponse, UsersCurrentUserError, UsersCurrentUserResponse, UsersDeleteUserData, UsersDeleteUserError, UsersDeleteUserResponse, UsersPatchCurrentUserData, UsersPatchCurrentUserError, UsersPatchCurrentUserResponse, UsersPatchUserData, UsersPatchUserError, UsersPatchUserResponse, UsersUserData, UsersUserError, UsersUserResponse, VerifyRequestTokenData, VerifyRequestTokenError, VerifyRequestTokenResponse, VerifyVerifyData, VerifyVerifyError, VerifyVerifyResponse } from './types.gen';

export const client = createClient(createConfig());

Expand Down Expand Up @@ -159,6 +159,14 @@ export const uploadMilestoneImage = <ThrowOnError extends boolean = false>(optio
url: '/admin/milestone-images/{milestone_id}'
}); };

/**
* Delete Milestone Image
*/
export const deleteMilestoneImage = <ThrowOnError extends boolean = false>(options: Options<DeleteMilestoneImageData, ThrowOnError>) => { return (options?.client ?? client).delete<DeleteMilestoneImageResponse, DeleteMilestoneImageError, ThrowOnError>({
...options,
url: '/admin/milestone-images/{milestone_image_id}'
}); };

/**
* Get User Questions Admin
*/
Expand Down
10 changes: 10 additions & 0 deletions frontend/src/lib/client/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,16 @@ export type UploadMilestoneImageResponse = (MilestoneImage);

export type UploadMilestoneImageError = (HTTPValidationError);

export type DeleteMilestoneImageData = {
path: {
milestone_image_id: number;
};
};

export type DeleteMilestoneImageResponse = (unknown);

export type DeleteMilestoneImageError = (HTTPValidationError);

export type GetUserQuestionsAdminResponse = (Array<UserQuestionAdmin>);

export type GetUserQuestionsAdminError = unknown;
Expand Down
23 changes: 23 additions & 0 deletions frontend/src/lib/components/Admin/EditImage.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<svelte:options runes={true} />

<script lang="ts">
import { preventDefault } from "$lib/util";
import { TrashBinOutline } from "flowbite-svelte-icons";
import Button from "flowbite-svelte/Button.svelte";
let {
filename,
ondelete,
}: { filename: string; ondelete: (event: Event) => void } = $props();
</script>

<div class="flex flex-col m-2 justify-center">
<img
src={`${import.meta.env.VITE_MONDEY_API_URL}/static/${filename}`}
width="96"
height="96"
alt={`${filename}`}

/>
<Button color="red" onclick={preventDefault(ondelete)} class="w-24 rounded-none mt-1"><TrashBinOutline class="h-5 w-5" /></Button>
</div>
53 changes: 40 additions & 13 deletions frontend/src/lib/components/Admin/EditMilestoneModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,17 @@
<script lang="ts">
import { refreshMilestoneGroups } from "$lib/admin.svelte";
import {
deleteLanguage,
deleteMilestoneImage,
updateMilestone,
uploadMilestoneImage,
} from "$lib/client/services.gen";
import type { MilestoneAdmin } from "$lib/client/types.gen";
import CancelButton from "$lib/components/Admin/CancelButton.svelte";
import DeleteModal from "$lib/components/Admin/DeleteModal.svelte";
import EditImage from "$lib/components/Admin/EditImage.svelte";
import SaveButton from "$lib/components/Admin/SaveButton.svelte";
import { getTranslations } from "$lib/i18n";
import {
ButtonGroup,
Fileupload,
Expand All @@ -22,10 +27,12 @@ import { _, locales } from "svelte-i18n";
let {
open = $bindable(false),
milestone,
milestone = $bindable(null),
}: { open: boolean; milestone: MilestoneAdmin | null } = $props();
let files: FileList | undefined = $state(undefined);
let images: Array<string> = $state([]);
let currentMilestoneImageId: number | null = $state(null as number | null);
let showDeleteMilestoneImageModal: boolean = $state(false);
const textKeys = ["title", "desc", "obs", "help"];
Expand All @@ -38,7 +45,7 @@ function updateImagesToUpload(event: Event) {
}
}
export async function saveChanges() {
async function saveChanges() {
if (!milestone) {
return;
}
Expand All @@ -58,9 +65,27 @@ export async function saveChanges() {
await refreshMilestoneGroups();
}
}
async function deleteMilestoneImageAndUpdate() {
if (!currentMilestoneImageId) {
return;
}
const { data, error } = await deleteMilestoneImage({
path: { milestone_image_id: currentMilestoneImageId },
});
if (error) {
console.log(error);
} else {
console.log(data);
milestone.images = milestone.images.filter(
(e) => e.id !== currentMilestoneImageId,
);
await refreshMilestoneGroups();
}
}
</script>

<Modal title={$_('admin.edit')} bind:open autoclose size="xl">
<Modal title={$_('admin.edit')} bind:open size="xl" outsideclose>
{#if milestone}
{#each textKeys as textKey}
{@const title = $_(`admin.${textKey}`)}
Expand All @@ -83,17 +108,17 @@ export async function saveChanges() {
<div class="mb-5">
<Label for="img_upload" class="pb-2">{$_('admin.images')}</Label>
<div class="flex flex-row">
{#each milestone.images as milestoneImage, milestoneImageId (milestoneImage.id)}
<img
src={`${import.meta.env.VITE_MONDEY_API_URL}/static/${milestoneImage.filename}`}
width="48"
height="48"
alt={`${milestoneImageId}`}
class="m-2"
{#each milestone.images as milestoneImage (milestoneImage.id)}
<EditImage
filename={milestoneImage.filename}
ondelete={() => {
currentMilestoneImageId = milestoneImage.id;
showDeleteMilestoneImageModal = true;
}}
/>
{/each}
{#each images as image}
<img src={image} width="48" height="48" alt="milestone" class="m-2" />
<img src={image} width="96" height="96" alt="milestone" class="w-24 h-24 m-2" />
{/each}
</div>
<Fileupload
Expand All @@ -107,7 +132,9 @@ export async function saveChanges() {
</div>
{/if}
<svelte:fragment slot="footer">
<SaveButton onclick={saveChanges} />
<CancelButton />
<SaveButton onclick={() => {open = false; saveChanges()}} />
<CancelButton onclick={() => {open = false;}} />
</svelte:fragment>
</Modal>

<DeleteModal bind:open={showDeleteMilestoneImageModal} onclick={deleteMilestoneImageAndUpdate}></DeleteModal>
2 changes: 1 addition & 1 deletion frontend/src/lib/components/Admin/MilestoneGroups.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ onMount(async () => {
></DeleteModal>

{#key showEditMilestoneModal}
<EditMilestoneModal bind:open={showEditMilestoneModal} milestone={currentMilestone}
<EditMilestoneModal bind:open={showEditMilestoneModal} bind:milestone={currentMilestone}
></EditMilestoneModal>
{/key}
<DeleteModal bind:open={showDeleteMilestoneModal} onclick={doDeleteMilestone}></DeleteModal>
6 changes: 2 additions & 4 deletions frontend/src/lib/components/Admin/Questions.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import DeleteModal from "$lib/components/Admin/DeleteModal.svelte";
import EditButton from "$lib/components/Admin/EditButton.svelte";
import EditQuestionModal from "$lib/components/Admin/EditQuestionModal.svelte";
import { childQuestions, userQuestions } from "$lib/stores/adminStore";
import { type Component, onMount } from "svelte";
import { onMount } from "svelte";
import { _, locale } from "svelte-i18n";
import type { Writable } from "svelte/store";
Expand All @@ -43,7 +43,6 @@ let create: any;
let doDelete: any;
let refresh: any;
let build: any;
let component: Component<any, any, string> = EditQuestionModal;
let questions:
| Writable<Array<UserQuestionAdmin>>
| Writable<Array<ChildQuestionAdmin>>
Expand Down Expand Up @@ -155,8 +154,7 @@ onMount(async () => {
</Card>

{#key showEditQuestionModal}
<svelte:component
this={component}
<EditQuestionModal
{kind}
bind:open={showEditQuestionModal}
question={currentQuestion}
Expand Down
44 changes: 27 additions & 17 deletions frontend/src/lib/components/ChildrenGallery.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -22,23 +22,23 @@ async function setup(): Promise<any> {
const childrenData = await Promise.all(
children.data.map(async (child) => {
let image = null;
if (child.has_image) {
const childImageResponse = await getChildImage({
path: { child_id: child.id },
const childImageResponse = await getChildImage({
path: { child_id: child.id },
});
console.log("childImageResponse", childImageResponse);
if (childImageResponse.error) {
console.log("Error when retrieving child image");
showAlert = true;
alertMessage =
$_("childData.alertMessageImage") +
" " +
childImageResponse.error.detail;
} else {
const reader = new FileReader();
reader.readAsDataURL(childImageResponse.data);
image = await new Promise((resolve) => {
reader.onloadend = () => resolve(reader.result as string);
});
if (childImageResponse.error) {
console.log("Error when retrieving child image");
showAlert = true;
alertMessage =
$_("childData.alertMessageImage") +
childImageResponse.error.detail;
} else {
const reader = new FileReader();
reader.readAsDataURL(childImageResponse.data);
image = await new Promise((resolve) => {
reader.onloadend = () => resolve(reader.result as string);
});
}
}
return {
header: child.name,
Expand All @@ -56,6 +56,7 @@ async function setup(): Promise<any> {
// add the 'new child' card as the first element
data = [
...childrenData,
{
header: $_("childData.newChildHeading"),
summary: $_("childData.newChildHeadingLong"),
Expand All @@ -67,7 +68,6 @@ async function setup(): Promise<any> {
},
image: null,
},
...childrenData,
];
}
return data;
Expand Down Expand Up @@ -129,6 +129,16 @@ const searchData = [
{#await promise}
<p>{"Waiting for server response"}</p>
{:then data}
{#if showAlert}
<AlertMessage
title={$_("childData.alertMessageTitle")}
message={alertMessage}
onclick={() => {
showAlert = false;
}}
/>
{/if}

<div class="container m-2 mx-auto w-full pb-4 md:rounded-t-lg">

<Heading
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/lib/components/ChildrenRegistration.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -244,12 +244,12 @@ async function submitImageData(): Promise<void> {
}
async function submitData(): Promise<void> {
// handle image data
await submitImageData();
// submit child data
await submitChildData();
// handle image data
await submitImageData();
// disable all elements to make editing a conscious choice amd go back to childrenGallery
console.log("submission of child data successful.");
activeTabChildren.set("childrenGallery");
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/routes/admin/+page.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<svelte:options runes={true} />

<script lang="ts">
import AdminPage from "$lib/components/AdminPage.svelte";
</script>

<AdminPage />
Binary file added mondey_backend/data/dummy.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion mondey_backend/openapi.json

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions mondey_backend/src/mondey_backend/routers/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,16 @@ async def upload_milestone_image(
session.refresh(milestone_image)
return milestone_image

@router.delete("/milestone-images/{milestone_image_id}")
async def delete_milestone_image(session: SessionDep, milestone_image_id: int):
milestone_image = get(session, MilestoneImage, milestone_image_id)
pathlib.Path(
f"{app_settings.STATIC_FILES_PATH}/m{milestone_image.id}.jpg"
).unlink(missing_ok=True)
session.delete(milestone_image)
session.commit()
return {"ok": True}

# User question CRUD endpoints
@router.get("/user-questions/", response_model=list[UserQuestionAdmin])
def get_user_questions_admin(session: SessionDep):
Expand Down
15 changes: 5 additions & 10 deletions mondey_backend/src/mondey_backend/routers/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,7 @@ async def get_child_image(
f"{app_settings.PRIVATE_FILES_PATH}/children/{child.id}.jpg"
)
if not image_path.exists():
raise HTTPException(404, detail="Image not found")
if child.has_image is False:
raise HTTPException(404, detail="Child does not have an image")
image_path = pathlib.Path(f"{app_settings.DATA_FILES_PATH}/dummy.jpg")
return image_path

@router.put("/children-images/{child_id}")
Expand All @@ -127,13 +125,10 @@ async def delete_child_image(
image_path = pathlib.Path(
f"{app_settings.PRIVATE_FILES_PATH}/children/{child.id}.jpg"
)
if not image_path.exists():
raise HTTPException(404, detail="Image not found")
else:
child.has_image = False
session.commit()
image_path.unlink()
return {"ok": True}
child.has_image = False
session.commit()
image_path.unlink(missing_ok=True)
return {"ok": True}

# milestone endpoints
@router.get(
Expand Down
1 change: 1 addition & 0 deletions mondey_backend/src/mondey_backend/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ class AppSettings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
# these defaults are for local development and are used if the environment variables are not set
SECRET: str = "abc123"
DATA_FILES_PATH: str = "data"
DATABASE_PATH: str = "db"
STATIC_FILES_PATH: str = "static"
PRIVATE_FILES_PATH: str = "private"
Expand Down
Loading

0 comments on commit 84c3f17

Please sign in to comment.