diff --git a/fastapi/app/api/v1/__init__.py b/fastapi/app/api/v1/__init__.py index 15981ba..3f91dff 100644 --- a/fastapi/app/api/v1/__init__.py +++ b/fastapi/app/api/v1/__init__.py @@ -1,2 +1,3 @@ from .auth import AuthAPI +from .student_sheet import StudentSheetAPI from .user import UserAPI diff --git a/fastapi/app/api/v1/student_sheet.py b/fastapi/app/api/v1/student_sheet.py new file mode 100644 index 0000000..897fcff --- /dev/null +++ b/fastapi/app/api/v1/student_sheet.py @@ -0,0 +1,63 @@ +from typing import List, Optional +from uuid import UUID + +from app.core.exceptions import ( + ApiException, + NotFoundObjectMatchingUuid, + PermissionDenied, +) +from app.crud import StudentSheetCRUD +from app.models import StudentSheet +from app.schemas import ( + CreateFormStudentSheetSchema, + CreateStudentSheetSchema, + UpdateFormStudentSheetSchema, + UpdateStudentSheetSchema, +) + +from fastapi import Request + + +class StudentSheetAPI: + @classmethod + def gets(cls, request: Request) -> List[StudentSheet]: + return StudentSheetCRUD(request.state.db_session).gets_by_user_uuid( + request.user.uuid + ) + + @classmethod + def get(cls, request: Request, uuid: UUID) -> Optional[StudentSheet]: + obj = StudentSheetCRUD(request.state.db_session).get_by_uuid(uuid) + if obj is None: + raise ApiException(NotFoundObjectMatchingUuid(StudentSheet, uuid)) + if obj.user_uuid != request.user.uuid: + raise ApiException(PermissionDenied()) + return obj + + @classmethod + def create( + cls, request: Request, schema: CreateFormStudentSheetSchema + ) -> StudentSheet: + data = CreateStudentSheetSchema(user_uuid=request.user.uuid, **schema.dict()) + return StudentSheetCRUD(request.state.db_session).create(data) + + @classmethod + def update( + cls, request: Request, uuid: UUID, schema: UpdateFormStudentSheetSchema + ) -> StudentSheet: + obj = StudentSheetCRUD(request.state.db_session).get_by_uuid(uuid) + if not obj: + raise ApiException(NotFoundObjectMatchingUuid(StudentSheet, uuid)) + if obj.user_uuid != request.user.uuid: + raise ApiException(PermissionDenied) + data = UpdateStudentSheetSchema(user_uuid=request.user.uuid, **schema.dict()) + return StudentSheetCRUD(request.state.db_session).update(uuid, data) + + @classmethod + def delete(cls, request: Request, uuid: UUID) -> None: + obj = StudentSheetCRUD(request.state.db_session).get_by_uuid(uuid) + if not obj: + raise ApiException(NotFoundObjectMatchingUuid(StudentSheet, uuid)) + if obj.user_uuid != request.user.uuid: + raise ApiException(PermissionDenied) + return StudentSheetCRUD(request.state.db_session).delete_by_uuid(uuid) diff --git a/fastapi/app/core/settings.py b/fastapi/app/core/settings.py index f5e12c8..461c891 100644 --- a/fastapi/app/core/settings.py +++ b/fastapi/app/core/settings.py @@ -1,4 +1,5 @@ import os +from datetime import timedelta, timezone from functools import lru_cache from pathlib import Path @@ -32,6 +33,9 @@ class Settings(BaseSettings): # firebase firebase_credentials_path: str = "/src/firebase_credentials.json" + # timezone + default_timezone = timezone(timedelta(hours=+9), "JST") + class Config: env_file = os.path.join(BASE_DIR, "fastapi.env") diff --git a/fastapi/app/crud/__init__.py b/fastapi/app/crud/__init__.py index b477529..353d5e9 100644 --- a/fastapi/app/crud/__init__.py +++ b/fastapi/app/crud/__init__.py @@ -1 +1,2 @@ +from .student_sheet import StudentSheetCRUD from .user import UserCRUD diff --git a/fastapi/app/crud/student.py b/fastapi/app/crud/student.py new file mode 100644 index 0000000..f17e2cb --- /dev/null +++ b/fastapi/app/crud/student.py @@ -0,0 +1,59 @@ +from typing import List, Optional +from uuid import UUID + +from app.core.exceptions import ApiException, NotFoundObjectMatchingUuid, create_error +from app.db.database import get_db_session +from app.models import Student +from app.schemas import CreateStudentSchema, UpdateStudentSchema +from sqlalchemy.orm import scoped_session + +from .base import BaseCRUD + +db_session = get_db_session() + + +class StudentCRUD(BaseCRUD): + def __init__(self, db_session: scoped_session): + super().__init__(db_session, Student) + + def gets_by_student_sheet_uuid(self, student_sheet_uuid: UUID) -> List[Student]: + return self.get_query().filter_by(student_sheet_uuid=student_sheet_uuid).all() + + def get_by_student_sheet_uuid_and_number( + self, student_sheet_uuid: UUID, number: int + ) -> Optional[Student]: + return ( + self.get_query() + .filter_by(student_sheet_uuid=student_sheet_uuid, number=number) + .first() + ) + + def create(self, schema: CreateStudentSchema) -> Student: + if ( + self.get_by_student_sheet_uuid_and_number( + schema.student_sheet_uuid, schema.number + ) + is not None + ): + raise ApiException( + create_error( + f"Student with number {schema.number} already exists in this student sheet." + ) + ) + return super().create(schema.dict()) + + def update(self, uuid: UUID, schema: UpdateStudentSchema) -> Student: + update_obj = self.get_by_uuid(uuid) + if update_obj is None: + raise ApiException(NotFoundObjectMatchingUuid(self.model, uuid)) + + new_data_obj = self.get_by_student_sheet_uuid_and_number( + schema.student_sheet_uuid, schema.number + ) + if new_data_obj is not None and new_data_obj.uuid != update_obj.uuid: + raise ApiException( + create_error( + f"Student with number {schema.number} already exists in this student sheet." + ) + ) + return super().update(uuid, schema.dict()) diff --git a/fastapi/app/crud/student_sheet.py b/fastapi/app/crud/student_sheet.py new file mode 100644 index 0000000..67b6064 --- /dev/null +++ b/fastapi/app/crud/student_sheet.py @@ -0,0 +1,44 @@ +from typing import List, Optional +from uuid import UUID + +from app.core.exceptions import ApiException, NotFoundObjectMatchingUuid, create_error +from app.db.database import get_db_session +from app.models import StudentSheet +from app.schemas import CreateStudentSheetSchema, UpdateStudentSheetSchema +from sqlalchemy.orm import scoped_session + +from .base import BaseCRUD + +db_session = get_db_session() + + +class StudentSheetCRUD(BaseCRUD): + def __init__(self, db_session: scoped_session): + super().__init__(db_session, StudentSheet) + + def gets_by_user_uuid(self, user_uuid: UUID) -> List[StudentSheet]: + return self.get_query().filter_by(user_uuid=user_uuid).all() + + def get_by_user_uuid_and_name( + self, user_uuid: UUID, name: str + ) -> Optional[StudentSheet]: + return self.get_query().filter_by(user_uuid=user_uuid, name=name).first() + + def create(self, schema: CreateStudentSheetSchema) -> StudentSheet: + if self.get_by_user_uuid_and_name(schema.user_uuid, schema.name) is not None: + raise ApiException( + create_error(f"StudentSheet with name {schema.name} already exists") + ) + return super().create(schema.dict()) + + def update(self, uuid: UUID, schema: UpdateStudentSheetSchema) -> StudentSheet: + update_obj = self.get_by_uuid(uuid) + if update_obj is None: + raise ApiException(NotFoundObjectMatchingUuid(self.model, uuid)) + + new_data_obj = self.get_by_user_uuid_and_name(schema.user_uuid, schema.name) + if new_data_obj is not None and new_data_obj.uuid != update_obj.uuid: + raise ApiException( + create_error(f"StudentSheet with name {schema.name} already exists") + ) + return super().update(uuid, schema.dict()) diff --git a/fastapi/app/crud/user.py b/fastapi/app/crud/user.py index 1b120c0..53dabf7 100644 --- a/fastapi/app/crud/user.py +++ b/fastapi/app/crud/user.py @@ -5,7 +5,12 @@ from app.models import User from sqlalchemy.orm import scoped_session -from ..core.exceptions import ApiException, FirebaseUidOrPasswordMustBeSet, create_error +from ..core.exceptions import ( + ApiException, + FirebaseUidOrPasswordMustBeSet, + NotFoundObjectMatchingUuid, + create_error, +) from ..core.security import get_password_hash from .base import BaseCRUD @@ -33,11 +38,11 @@ def create(self, data: dict = {}) -> User: def update(self, uuid: UUID, data: dict = {}) -> User: # FIXME: セキュリティ的に、そのメールアドレスのユーザーが存在することを伝えるのはよくない? - if ( - self.get_by_uuid(uuid) is not None - and data["email"] != self.get_by_uuid(uuid).email - and self.get_query().filter_by(email=data["email"]).first() - ): + update_obj = self.get_by_uuid(uuid) + if update_obj is None: + raise ApiException(NotFoundObjectMatchingUuid(self.model, uuid)) + new_data_obj = self.get_query().filter_by(email=data["email"]).first() + if new_data_obj is not None and new_data_obj.uuid != update_obj.uuid: raise ApiException( create_error(f"User with email {data['email']} already exists") ) diff --git a/fastapi/app/db/data.py b/fastapi/app/db/data.py index 1d2fd2e..de8a86e 100644 --- a/fastapi/app/db/data.py +++ b/fastapi/app/db/data.py @@ -12,3 +12,34 @@ "password": "password", }, ] + +STUDENT_SHEETS = [ + { + "name": "3-A (1)", + "user": USERS[0], + }, + { + "name": "3-A (2)", + "user": USERS[0], + }, + { + "name": "3-A (3)", + "user": USERS[0], + }, + { + "name": "3-B (1)", + "user": USERS[1], + }, + { + "name": "3-B (2)", + "user": USERS[1], + }, + { + "name": "3-C (1)", + "user": USERS[2], + }, + { + "name": "3-C (2)", + "user": USERS[2], + }, +] diff --git a/fastapi/app/db/migrations/versions/20220103_175531_student_sheet.py b/fastapi/app/db/migrations/versions/20220103_175531_student_sheet.py new file mode 100644 index 0000000..94e6ea9 --- /dev/null +++ b/fastapi/app/db/migrations/versions/20220103_175531_student_sheet.py @@ -0,0 +1,50 @@ +"""student_sheet + +Revision ID: 8bf6f6f70d43 +Revises: c2dc0f9395d6 +Create Date: 2022-01-03 17:55:31.902638+09:00 + +""" +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "8bf6f6f70d43" +down_revision = "c2dc0f9395d6" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "student_sheets", + sa.Column("uuid", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column( + "created_at", + postgresql.TIMESTAMP(timezone=True), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + comment="登録日時", + ), + sa.Column( + "updated_at", + postgresql.TIMESTAMP(timezone=True), + nullable=True, + comment="最終更新日時", + ), + sa.Column("is_active", sa.BOOLEAN(), server_default="true", nullable=False), + sa.Column("name", sa.VARCHAR(length=100), nullable=False), + sa.Column("user_uuid", postgresql.UUID(as_uuid=True), nullable=False), + sa.ForeignKeyConstraint(["user_uuid"], ["users.uuid"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("uuid"), + sa.UniqueConstraint("name", "user_uuid"), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("student_sheets") + # ### end Alembic commands ### diff --git a/fastapi/app/db/migrations/versions/20220104_110934_student.py b/fastapi/app/db/migrations/versions/20220104_110934_student.py new file mode 100644 index 0000000..20f23f0 --- /dev/null +++ b/fastapi/app/db/migrations/versions/20220104_110934_student.py @@ -0,0 +1,54 @@ +"""student + +Revision ID: d0c79969f425 +Revises: 8bf6f6f70d43 +Create Date: 2022-01-04 11:09:34.821871+09:00 + +""" +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "d0c79969f425" +down_revision = "8bf6f6f70d43" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "students", + sa.Column("uuid", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column( + "created_at", + postgresql.TIMESTAMP(timezone=True), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + comment="登録日時", + ), + sa.Column( + "updated_at", + postgresql.TIMESTAMP(timezone=True), + nullable=True, + comment="最終更新日時", + ), + sa.Column("is_active", sa.BOOLEAN(), server_default="true", nullable=False), + sa.Column("name", sa.VARCHAR(length=100), nullable=False), + sa.Column("number", sa.INTEGER(), nullable=False), + sa.Column("student_sheet_uuid", postgresql.UUID(as_uuid=True), nullable=False), + sa.CheckConstraint("number > 0"), + sa.ForeignKeyConstraint( + ["student_sheet_uuid"], ["student_sheets.uuid"], ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("uuid"), + sa.UniqueConstraint("number", "student_sheet_uuid"), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("students") + # ### end Alembic commands ### diff --git a/fastapi/app/db/seed.py b/fastapi/app/db/seed.py index 14af5a6..9e78305 100644 --- a/fastapi/app/db/seed.py +++ b/fastapi/app/db/seed.py @@ -1,8 +1,8 @@ from logging import getLogger -from app.crud import UserCRUD +from app.crud import StudentSheetCRUD, UserCRUD from app.db.database import get_db_session -from app.schemas import CreateUserSchema +from app.schemas import CreateStudentSheetSchema, CreateUserSchema logger = getLogger(__name__) @@ -23,9 +23,27 @@ def seed_users(users): db_session.commit() +def seed_student_sheets(student_sheets): + for student_sheet in student_sheets: + user = UserCRUD(db_session).get_by_email(student_sheet["user"]["email"]) + assert user is not None + if not StudentSheetCRUD(db_session).get_by_user_uuid_and_name( + user.uuid, student_sheet["name"] + ): + student_sheet_schema = CreateStudentSheetSchema( + name=student_sheet["name"], user_uuid=user.uuid + ) + StudentSheetCRUD(db_session).create(student_sheet_schema) + logger.info(f"Created student sheet: {student_sheet['name']}") + else: + logger.info(f"Skipped student sheet: {student_sheet['name']}") + db_session.commit() + + def seed_all(): - from .data import USERS + from .data import STUDENT_SHEETS, USERS logger.info("Seeding data...") seed_users(USERS) + seed_student_sheets(STUDENT_SHEETS) logger.info("done") diff --git a/fastapi/app/models/__init__.py b/fastapi/app/models/__init__.py index ee4c00b..fbb0aa3 100644 --- a/fastapi/app/models/__init__.py +++ b/fastapi/app/models/__init__.py @@ -1 +1,3 @@ +from .student import Student +from .student_sheet import StudentSheet from .user import User diff --git a/fastapi/app/models/student.py b/fastapi/app/models/student.py new file mode 100644 index 0000000..e967c27 --- /dev/null +++ b/fastapi/app/models/student.py @@ -0,0 +1,25 @@ +from sqlalchemy import INTEGER, VARCHAR, CheckConstraint, Column, ForeignKey +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.schema import UniqueConstraint + +from .base import BaseModelMixin + + +class Student(BaseModelMixin): + __tablename__ = "students" + + MAX_LENGTH_NAME = 100 + name = Column(VARCHAR(MAX_LENGTH_NAME), nullable=False) + + number = Column(INTEGER, nullable=False) + + student_sheet_uuid = Column( + UUID(as_uuid=True), + ForeignKey("student_sheets.uuid", ondelete="CASCADE"), + nullable=False, + ) + + __table_args__ = ( + UniqueConstraint("number", "student_sheet_uuid"), + CheckConstraint(number > 0), + ) diff --git a/fastapi/app/models/student_sheet.py b/fastapi/app/models/student_sheet.py new file mode 100644 index 0000000..94afc11 --- /dev/null +++ b/fastapi/app/models/student_sheet.py @@ -0,0 +1,22 @@ +from sqlalchemy import VARCHAR, Column, ForeignKey +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship +from sqlalchemy.schema import UniqueConstraint + +from .base import BaseModelMixin + + +class StudentSheet(BaseModelMixin): + __tablename__ = "student_sheets" + __table_args__ = UniqueConstraint("name", "user_uuid"), {} + + MAX_LENGTH_NAME = 100 + name = Column(VARCHAR(MAX_LENGTH_NAME), nullable=False) + + user_uuid = Column( + UUID(as_uuid=True), + ForeignKey("users.uuid", ondelete="CASCADE"), + nullable=False, + ) + + students = relationship("Student", backref="student_sheet", cascade="all") diff --git a/fastapi/app/models/user.py b/fastapi/app/models/user.py index 9ae8cdd..078d74a 100644 --- a/fastapi/app/models/user.py +++ b/fastapi/app/models/user.py @@ -1,4 +1,5 @@ from sqlalchemy import BOOLEAN, VARCHAR, Column, String +from sqlalchemy.orm import relationship from .base import BaseModelMixin @@ -14,3 +15,5 @@ class User(BaseModelMixin): firebase_uid = Column(VARCHAR(MAX_LENGTH_FIREBASE_UID), unique=True, nullable=True) is_admin = Column(BOOLEAN, nullable=False, default=False) + + student_sheets = relationship("StudentSheet", backref="user", cascade="all") diff --git a/fastapi/app/routers/v1/__init__.py b/fastapi/app/routers/v1/__init__.py index 4067000..0261fe3 100644 --- a/fastapi/app/routers/v1/__init__.py +++ b/fastapi/app/routers/v1/__init__.py @@ -1,8 +1,12 @@ from fastapi import APIRouter from .auth import auth_router +from .studnet_sheet import student_sheet_router from .user import user_router api_v1_router = APIRouter() api_v1_router.include_router(user_router, prefix="/users", tags=["users"]) api_v1_router.include_router(auth_router, prefix="/auth", tags=["auth"]) +api_v1_router.include_router( + student_sheet_router, prefix="/student-sheets", tags=["student sheets"] +) diff --git a/fastapi/app/routers/v1/studnet_sheet.py b/fastapi/app/routers/v1/studnet_sheet.py new file mode 100644 index 0000000..a720904 --- /dev/null +++ b/fastapi/app/routers/v1/studnet_sheet.py @@ -0,0 +1,60 @@ +from typing import List, Optional +from uuid import UUID + +from app.api.v1 import StudentSheetAPI +from app.dependencies.auth import login_required +from app.models import StudentSheet +from app.schemas import ( + CreateFormStudentSheetSchema, + ReadStudentSheetSchema, + UpdateFormStudentSheetSchema, +) + +from fastapi import APIRouter, Depends, Request + +student_sheet_router = APIRouter() + + +@student_sheet_router.get( + "/", + response_model=List[ReadStudentSheetSchema], + dependencies=[Depends(login_required)], +) +async def gets(request: Request) -> List[StudentSheet]: + return StudentSheetAPI.gets(request) + + +@student_sheet_router.get( + "/{uuid}", + response_model=ReadStudentSheetSchema, + dependencies=[Depends(login_required)], +) +async def get(request: Request, uuid: UUID) -> Optional[StudentSheet]: + return StudentSheetAPI.get(request, uuid) + + +@student_sheet_router.post( + "/", response_model=ReadStudentSheetSchema, dependencies=[Depends(login_required)] +) +async def create( + request: Request, schema: CreateFormStudentSheetSchema +) -> StudentSheet: + return StudentSheetAPI.create(request, schema) + + +@student_sheet_router.put( + "/{uuid}", + response_model=ReadStudentSheetSchema, + dependencies=[Depends(login_required)], +) +async def update( + request: Request, + uuid: UUID, + schema: UpdateFormStudentSheetSchema, +) -> StudentSheet: + return StudentSheetAPI.update(request, uuid, schema) + + +@student_sheet_router.delete("/{uuid}", dependencies=[Depends(login_required)]) +async def delete(request: Request, uuid: UUID) -> None: + return StudentSheetAPI.delete(request, uuid) diff --git a/fastapi/app/schemas/__init__.py b/fastapi/app/schemas/__init__.py index be1d016..681350e 100644 --- a/fastapi/app/schemas/__init__.py +++ b/fastapi/app/schemas/__init__.py @@ -1,2 +1,4 @@ from .auth import * +from .student import * +from .student_sheet import * from .user import * diff --git a/fastapi/app/schemas/student.py b/fastapi/app/schemas/student.py new file mode 100644 index 0000000..f308671 --- /dev/null +++ b/fastapi/app/schemas/student.py @@ -0,0 +1,32 @@ +from uuid import UUID + +from app.models import Student +from pydantic import BaseModel, Field + + +class BaseStudentSchema(BaseModel): + name: str = Field(..., max_length=Student.MAX_LENGTH_NAME) + number: int = Field(..., gt=0) + + class Config: + orm_mode = True + + +class CreateStudentSchema(BaseStudentSchema): + student_sheet_uuid: UUID + + +class UpdateStudentSchema(BaseStudentSchema): + student_sheet_uuid: UUID + + +class CreateFormStudentSchema(BaseStudentSchema): + pass + + +class UpdateFormStudentSchema(BaseStudentSchema): + pass + + +class ReadStudentSchema(BaseStudentSchema): + uuid: UUID diff --git a/fastapi/app/schemas/student_sheet.py b/fastapi/app/schemas/student_sheet.py new file mode 100644 index 0000000..1d691dc --- /dev/null +++ b/fastapi/app/schemas/student_sheet.py @@ -0,0 +1,45 @@ +from datetime import datetime +from typing import Optional +from uuid import UUID + +from app.core.settings import get_env +from app.models import StudentSheet +from pydantic import BaseModel, Field, validator + +from .user import ReadUserSchema + + +class BaseStudentSheetSchema(BaseModel): + name: str = Field(..., max_length=StudentSheet.MAX_LENGTH_NAME) + + class Config: + orm_mode = True + + +class CreateStudentSheetSchema(BaseStudentSheetSchema): + user_uuid: UUID + + +class UpdateStudentSheetSchema(BaseStudentSheetSchema): + user_uuid: UUID + + +class CreateFormStudentSheetSchema(BaseStudentSheetSchema): + pass + + +class UpdateFormStudentSheetSchema(BaseStudentSheetSchema): + pass + + +class ReadStudentSheetSchema(BaseStudentSheetSchema): + uuid: UUID + user: ReadUserSchema + created_at: datetime + updated_at: Optional[datetime] + + @validator("created_at", "updated_at", pre=True) + def convert_to_default_timezone( + cls, value: Optional[datetime] + ) -> Optional[datetime]: + return value and value.astimezone(get_env().default_timezone)