diff --git a/api/serializers.py b/api/serializers.py index d75a0b8..2d390f7 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -5,14 +5,20 @@ from django.conf import settings from jsonschema.validators import Draft7Validator from rest_framework import serializers -from jsonschema import validate, ValidationError from diary.models import Diary class DiarySerializer(serializers.ModelSerializer): + BASE_JSON_SCHEMA_FILE_DIR = settings.BASE_DIR / 'frontend' / 'src' / 'jsonSchemas' + + def get_json_schema_file_name(self): + if self.instance: + return self.BASE_JSON_SCHEMA_FILE_DIR / f'diary_{self.instance.version}.json' + return self.BASE_JSON_SCHEMA_FILE_DIR / f'diary_{settings.CURRENT_JSON_SCHEMA_VERSION}.json' + def validate(self, attrs): - with open(settings.BASE_DIR / 'frontend' / 'src' / 'jsonSchemas' / 'diary.json') as f: + with open(self.get_json_schema_file_name()) as f: json_schema = json.load(f) errors = Draft7Validator(json_schema).iter_errors(attrs) @@ -27,7 +33,10 @@ def create(self, validated_data): ModelClass = self.Meta.model target_data = deepcopy(validated_data) - target_data['name'] = validated_data['content']['name'] + + variety = validated_data['content'].get('variety') + target_data['name'] = variety or validated_data['content']['name'] + target_data['version'] = settings.CURRENT_JSON_SCHEMA_VERSION try: instance = ModelClass._default_manager.create(**target_data) @@ -59,11 +68,12 @@ def update(self, instance, validated_data): setattr(instance, attr, value) if attr == 'content': - setattr(instance, 'name', value['name']) + variety = value.get('variety') + setattr(instance, 'name', variety or value['name']) instance.save() return instance class Meta: model = Diary - fields = ['id', 'name', 'content', 'updated_at'] + fields = ['id', 'name', 'content', 'version', 'updated_at'] diff --git a/api/tests/test_serializers.py b/api/tests/test_serializers.py index 30325ae..bebc289 100644 --- a/api/tests/test_serializers.py +++ b/api/tests/test_serializers.py @@ -1,81 +1,204 @@ +import pytest + from api.serializers import DiarySerializer -def test_JSONSchemaの仕様を満たす(): - input_data = { - 'content': { - 'name': '3文字', - 'note': '5文字です' +class TestVersion20231224: + @pytest.fixture + def version(self, settings): + settings.CURRENT_JSON_SCHEMA_VERSION = '2023_1224' + + def test_JSONSchemaの仕様を満たす(self, version): + input_data = { + 'content': { + 'name': '3文字', + 'note': '5文字です' + } + } + + serializer = DiarySerializer(data=input_data) + assert serializer.is_valid() + assert serializer.errors == {} + + def test_nameが2文字(self, version): + input_data = { + 'content': { + 'name': '2字', + 'note': '5文字です' + } } - } - serializer = DiarySerializer(data=input_data) - assert serializer.is_valid() - assert serializer.errors == {} + serializer = DiarySerializer(data=input_data) + assert serializer.is_valid() is False + assert serializer.errors['non_field_errors'] == ["'2字' is too short"] + def test_noteが4文字(self, version): + input_data = { + 'content': { + 'name': '3文字', + 'note': '4文字だ' + } + } + + serializer = DiarySerializer(data=input_data) + assert serializer.is_valid() is False + assert serializer.errors['non_field_errors'] == ["'4文字だ' is too short"] -def test_nameが2文字(): - input_data = { - 'content': { - 'name': '2字', - 'note': '5文字です' + def test_nameとnoteが短すぎる(self, version): + input_data = { + 'content': { + 'name': '2字', + 'note': '4文字だ' + } } - } - serializer = DiarySerializer(data=input_data) - assert serializer.is_valid() is False - assert serializer.errors['non_field_errors'] == ["'2字' is too short"] + serializer = DiarySerializer(data=input_data) + assert serializer.is_valid() is False + assert serializer.errors['non_field_errors'] == ["'2字' is too short", "'4文字だ' is too short"] + def test_nameとnoteが数字(self, version): + input_data = { + 'content': { + 'name': 123, + 'note': 12345 + } + } -def test_noteが4文字(): - input_data = { - 'content': { - 'name': '3文字', - 'note': '4文字だ' + serializer = DiarySerializer(data=input_data) + assert serializer.is_valid() is False + assert serializer.errors['non_field_errors'] == ["123 is not of type 'string'", "12345 is not of type 'string'"] + + def test_追加のプロパティが存在するためNG(self, version): + input_data = { + 'content': { + 'name': '3文字', + 'note': '5文字です', + 'additional': '追加したデータ' + } } - } - serializer = DiarySerializer(data=input_data) - assert serializer.is_valid() is False - assert serializer.errors['non_field_errors'] == ["'4文字だ' is too short"] + serializer = DiarySerializer(data=input_data) + assert serializer.is_valid() is False + assert serializer.errors['non_field_errors'] \ + == ["Additional properties are not allowed ('additional' was unexpected)"] -def test_nameとnoteが短すぎる(): - input_data = { - 'content': { - 'name': '2字', - 'note': '4文字だ' +class TestVersion20231225: + @pytest.fixture + def version(self, settings): + settings.CURRENT_JSON_SCHEMA_VERSION = '2023_1225' + + def test_黄色のシナノゴールドで仕様を満たす(self, version): + input_data = { + 'content': { + 'color': '黄', + 'name': 'シナノゴールド', + 'note': 'おいしいです' + } } - } - serializer = DiarySerializer(data=input_data) - assert serializer.is_valid() is False - assert serializer.errors['non_field_errors'] == ["'2字' is too short", "'4文字だ' is too short"] + serializer = DiarySerializer(data=input_data) + assert serializer.is_valid() + assert serializer.errors == {} + + def test_緑色のブラムリーで仕様を満たす(self, version): + input_data = { + 'content': { + 'color': '緑', + 'name': 'ブラムリー', + 'note': 'おいしいです' + } + } + + serializer = DiarySerializer(data=input_data) + assert serializer.is_valid() + assert serializer.errors == {} + + def test_赤色の秋映で仕様を満たす(self, version): + input_data = { + 'content': { + 'color': '赤', + 'name': '秋映', + 'note': 'おいしいです' + } + } + + serializer = DiarySerializer(data=input_data) + assert serializer.is_valid() + assert serializer.errors == {} -def test_nameとnoteが数字(): - input_data = { - 'content': { - 'name': 123, - 'note': 12345 + def test_赤色のその他の名前で仕様を満たす(self, version): + input_data = { + 'content': { + 'color': '赤', + 'name': 'その他', + 'variety': '奥州ロマン', + 'note': 'おいしいです' + } } - } - serializer = DiarySerializer(data=input_data) - assert serializer.is_valid() is False - assert serializer.errors['non_field_errors'] == ["123 is not of type 'string'", "12345 is not of type 'string'"] + serializer = DiarySerializer(data=input_data) + assert serializer.is_valid() + assert serializer.errors == {} -def test_追加のプロパティが存在するためNG(): - input_data = { - 'content': { - 'name': '3文字', - 'note': '5文字です', - 'additional': '追加したデータ' + def test_色と名前が不一致(self, version): + input_data = { + 'content': { + 'color': '赤', + 'name': 'シナノゴールド', + 'note': 'おいしいです' + } + } + + serializer = DiarySerializer(data=input_data) + assert serializer.is_valid() is False + assert serializer.errors['non_field_errors'] == \ + ["{'color': '赤', 'name': 'シナノゴールド', 'note': 'おいしいです'} is not valid under any of the given schemas"] + + def test_色が存在しない(self, version): + input_data = { + 'content': { + 'color': '青', + 'name': 'その他', + 'variety': 'ブルーロマン', + 'note': 'おいしいです' + } + } + + serializer = DiarySerializer(data=input_data) + assert serializer.is_valid() is False + assert serializer.errors['non_field_errors'] == \ + [ + "'青' is not valid under any of the given schemas", + "{'color': '青', 'name': 'その他', 'variety': 'ブルーロマン', 'note': 'おいしいです'} is not valid under any of the given schemas" + ] + + def test_その他の時にvarietyがない(self, version): + input_data = { + 'content': { + 'color': '赤', + 'name': 'その他', + 'note': 'おいしいです' + } + } + + serializer = DiarySerializer(data=input_data) + assert serializer.is_valid() is False + assert serializer.errors['non_field_errors'] == ["'variety' is a required property"] + + def test_その他でない時にvarietyがある(self, version): + input_data = { + 'content': { + 'color': '赤', + 'name': '秋映', + 'note': 'おいしいです', + 'variety': 'シナノスイート' + } } - } - serializer = DiarySerializer(data=input_data) - assert serializer.is_valid() is False - assert serializer.errors['non_field_errors'] \ - == ["Additional properties are not allowed ('additional' was unexpected)"] + serializer = DiarySerializer(data=input_data) + assert serializer.is_valid() is False + assert serializer.errors['non_field_errors'] \ + == ["{'color': '赤', 'name': '秋映', 'note': 'おいしいです', 'variety': 'シナノスイート'} should not be valid under {'required': ['variety']}"] diff --git a/config/settings.py b/config/settings.py index 3df0186..e78b504 100644 --- a/config/settings.py +++ b/config/settings.py @@ -9,8 +9,9 @@ For the full list of settings and their values, see https://docs.djangoproject.com/en/4.2/ref/settings/ """ - +import os from pathlib import Path +from dotenv import load_dotenv # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent @@ -147,3 +148,6 @@ "http://localhost:8000", "http://localhost:5173", ] + +# for JSON Schema current version +CURRENT_JSON_SCHEMA_VERSION = str(os.getenv('CURRENT_JSON_SCHEMA_VERSION', '2023_1225')) diff --git a/diary/migrations/0002_diary_version.py b/diary/migrations/0002_diary_version.py new file mode 100644 index 0000000..697b265 --- /dev/null +++ b/diary/migrations/0002_diary_version.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.8 on 2023-12-25 05:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('diary', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='diary', + name='version', + field=models.CharField(default='2023_1224', max_length=50), + ), + ] diff --git a/diary/models.py b/diary/models.py index 98c441c..bdef80e 100644 --- a/diary/models.py +++ b/diary/models.py @@ -4,4 +4,5 @@ class Diary(models.Model): name = models.CharField(max_length=50, blank=True, null=False) content = models.JSONField(blank=True, null=False) + version = models.CharField(max_length=50, null=False, default='2023_1224') updated_at = models.DateTimeField(auto_now=True) diff --git a/frontend/src/components/pages/Edit.tsx b/frontend/src/components/pages/Edit.tsx index 76e4fc1..f891bef 100644 --- a/frontend/src/components/pages/Edit.tsx +++ b/frontend/src/components/pages/Edit.tsx @@ -1,24 +1,19 @@ import {Link, useNavigate, useParams} from "@tanstack/react-router"; import validator from '@rjsf/validator-ajv8'; import Form from '@rjsf/mui'; -import jsonSchema from "@/jsonSchemas/diary.json" import {useDiary} from "@/hooks/useDiary"; import React, {useEffect, useState} from "react"; import {useUiSchema} from "@/hooks/useUiSchema"; import Stack from "@mui/material/Stack"; +import {FormData, useSchema} from "../../hooks/useSchema"; +import {useWidget} from "../../hooks/useWidget"; -type FormData = { - content: { - name: string - note: string - } -} - type ApiResponse = { id: number, name: string, content: FormData, + version: string, updated_at: string } @@ -34,6 +29,10 @@ export const Edit = () => { const [formData, setFormData] = useState() const {uiSchema} = useUiSchema() + const [version, setVersion] = useState('2023_1224') + const {importJsonSchema, toFormData} = useSchema() + const {widgets} = useWidget() + // https://rjsf-team.github.io/react-jsonschema-form/docs/api-reference/form-props#onsubmit const handleSubmit = ({formData}, _event) => { mutate(formData.content, { @@ -46,18 +45,17 @@ export const Edit = () => { useEffect(() => { if (!data) return - setFormData( - { - content: { - name: data.content.name, - note: data.content.note - } - } - ) + setVersion(data.version) + + setFormData(toFormData(data, data.version)) }, [data]) if (isLoading) return
Loading
+ const formContext = { + version: version + } + return ( <> @@ -68,7 +66,9 @@ export const Edit = () => { - schema={jsonSchema} uiSchema={uiSchema} validator={validator} formData={formData} + schema={importJsonSchema(version)} uiSchema={uiSchema} validator={validator} formData={formData} + widgets={widgets} + formContext={formContext} onSubmit={handleSubmit}/> ) diff --git a/frontend/src/components/pages/New.tsx b/frontend/src/components/pages/New.tsx index b185319..e98738f 100644 --- a/frontend/src/components/pages/New.tsx +++ b/frontend/src/components/pages/New.tsx @@ -1,11 +1,14 @@ import {Link, useNavigate} from "@tanstack/react-router"; import validator from '@rjsf/validator-ajv8'; import Form from '@rjsf/mui'; -import jsonSchema from "@/jsonSchemas/diary.json" import {useDiary} from "@/hooks/useDiary"; import {useUiSchema} from "@/hooks/useUiSchema"; import Stack from "@mui/material/Stack"; import React from "react"; +import {useSchema} from "../../hooks/useSchema"; +import MyTextWidget from "../widgets/MyTextWidget"; +import {RegistryWidgetsType} from "@rjsf/utils"; +import {useWidget} from "../../hooks/useWidget"; type FormData = { @@ -22,6 +25,8 @@ export const New = () => { const navigate = useNavigate() const {uiSchema} = useUiSchema() + const {importCurrentJsonSchema} = useSchema() + const {widgets} = useWidget() // https://rjsf-team.github.io/react-jsonschema-form/docs/api-reference/form-props#onsubmit const handleSubmit = ({formData}, _event) => { @@ -33,6 +38,10 @@ export const New = () => { }) } + const formContext = { + version: import.meta.env.VITE_CURRENT_JSON_SCHEMA_VERSION + } + return ( <> @@ -42,7 +51,10 @@ export const New = () => { - schema={jsonSchema} uiSchema={uiSchema} validator={validator} onSubmit={handleSubmit}/> + schema={importCurrentJsonSchema()} uiSchema={uiSchema} validator={validator} onSubmit={handleSubmit} + widgets={widgets} + formContext={formContext} + /> ) } \ No newline at end of file diff --git a/frontend/src/components/widgets/MyTextWidget.tsx b/frontend/src/components/widgets/MyTextWidget.tsx new file mode 100644 index 0000000..e296f7f --- /dev/null +++ b/frontend/src/components/widgets/MyTextWidget.tsx @@ -0,0 +1,16 @@ +import { getTemplate, FormContextType, RJSFSchema, StrictRJSFSchema, WidgetProps } from '@rjsf/utils'; + +export default function MyTextWidget( + props: WidgetProps +) { + const { options, registry, formContext } = props; + const BaseInputTemplate = getTemplate<'BaseInputTemplate', T, S, F>('BaseInputTemplate', registry, options); + + const version = formContext.version + + if (version !== import.meta.env.VITE_CURRENT_JSON_SCHEMA_VERSION) { + return ; + } + + return ; +} \ No newline at end of file diff --git a/frontend/src/hooks/useSchema.ts b/frontend/src/hooks/useSchema.ts new file mode 100644 index 0000000..ad7f9e9 --- /dev/null +++ b/frontend/src/hooks/useSchema.ts @@ -0,0 +1,58 @@ +import jsonSchema_2023_1224 from "@/jsonSchemas/diary_2023_1224.json" +import jsonSchema_2023_1225 from "@/jsonSchemas/diary_2023_1225.json" + +type FormData20231224 = { + content: { + name: string + note: string + } +} + +type FormData20231225 = { + content: { + color: string, + name: string, + variety?: string + note: string + } +} + +export type FormData = FormData20231224 | FormData20231225 + +export const useSchema = () => { + const importJsonSchema = (version: string) => { + switch (version) { + case '2023_1225': + return jsonSchema_2023_1225 + default: + return jsonSchema_2023_1224 + } + } + + const importCurrentJsonSchema = () => { + return importJsonSchema(import.meta.env.VITE_CURRENT_JSON_SCHEMA_VERSION) + } + + const toFormData = (responseData, version): FormData => { + switch (version) { + case '2023_1225': + return { + content: { + color: responseData.content.color, + name: responseData.content.name, + variety: responseData.content?.variety, + note: responseData.content.note + } + } + default: + return { + content: { + name: responseData.content.name, + note: responseData.content.note + } + } + } + } + + return {importJsonSchema, importCurrentJsonSchema, toFormData} +} \ No newline at end of file diff --git a/frontend/src/hooks/useWidget.ts b/frontend/src/hooks/useWidget.ts new file mode 100644 index 0000000..9dd00f1 --- /dev/null +++ b/frontend/src/hooks/useWidget.ts @@ -0,0 +1,10 @@ +import {RegistryWidgetsType} from "@rjsf/utils"; +import MyTextWidget from "../components/widgets/MyTextWidget"; + +export const useWidget = () => { + const widgets: RegistryWidgetsType = { + TextWidget: MyTextWidget + } + + return {widgets} +} \ No newline at end of file diff --git a/frontend/src/jsonSchemas/diary_2023_1224.json b/frontend/src/jsonSchemas/diary_2023_1224.json new file mode 100644 index 0000000..70a541d --- /dev/null +++ b/frontend/src/jsonSchemas/diary_2023_1224.json @@ -0,0 +1,25 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "#id": "https://example.com/thinkami_2023_1224.json", + "title": "Diary", + "$comment": "version 2023_1224", + "type": "object", + "required": ["content"], + "properties": { + "content": { + "type": "object", + "required": ["name", "note"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "minLength": 3 + }, + "note": { + "type": "string", + "minLength": 5 + } + } + } + } +} \ No newline at end of file diff --git a/frontend/src/jsonSchemas/diary_2023_1225.json b/frontend/src/jsonSchemas/diary_2023_1225.json new file mode 100644 index 0000000..3721989 --- /dev/null +++ b/frontend/src/jsonSchemas/diary_2023_1225.json @@ -0,0 +1,139 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "#id": "https://example.com/thinkami_2023_1225.json", + "title": "Diary", + "$comment": "version 2023_1225", + "type": "object", + "required": ["content"], + "definitions": { + "note": { + "type": "string", + "minLength": 5 + } + }, + "properties": { + "content": { + "type": "object", + "required": ["color", "name", "note"], + "properties": { + "color": { + "type": "string", + "oneOf": [ + { + "const": "黄" + }, + { + "const": "緑" + }, + { + "const": "赤" + } + ] + } + }, + "dependencies": { + "color": { + "oneOf": [ + { + "required": ["name"], + "properties": { + "color": { + "const": "黄" + }, + "name": { + "type": "string", + "oneOf": [ + { + "const": "シナノゴールド" + }, + { + "const": "トキ" + }, + { + "const": "その他" + } + ] + } + } + }, + { + "required": ["name"], + "properties": { + "color": { + "const": "緑" + }, + "name": { + "type": "string", + "oneOf": [ + { + "const": "ブラムリー" + }, + { + "const": "グラニースミス" + }, + { + "const": "その他" + } + ] + } + } + }, + { + "required": ["name"], + "properties": { + "color": { + "const": "赤" + }, + "name": { + "type": "string", + "oneOf": [ + { + "const": "フジ" + }, + { + "const": "秋映" + }, + { + "const": "その他" + } + ] + } + } + } + ] + }, + "name": { + "if": { + "properties": { + "name": { + "const": "その他" + } + } + }, + "then": { + "required": ["variety", "note"], + "properties": { + "variety": { + "type": "string" + }, + "note": { + "$ref": "#/definitions/note" + } + } + }, + "else": { + "required": ["note"], + "not": { + "required": ["variety"] + }, + "properties": { + "note": { + "$ref": "#/definitions/note" + } + } + } + } + } + } + } +} \ No newline at end of file