├── .gitignore ├── alembic.ini ├── app ├── config.py ├── dao │ └── base.py ├── database.py ├── exceptions.py ├── main.py ├── majors │ ├── dao.py │ ├── models.py │ ├── router.py │ └── schemas.py ├── migration │ ├── README │ ├── env.py │ ├── script.py.mako │ └── versions │ │ ├── 3bc793e24f23_add.py │ │ ├── 3e41a2ab1fca_create_instruction_table.py │ │ └── c9bb7d635f6d_add_column_photo.py ├── pages │ └── router.py ├── static │ ├── images │ │ ├── 2.webp │ │ ├── 3.webp │ │ └── 4.webp │ ├── js │ │ └── script.js │ └── style │ │ ├── register.css │ │ ├── student.css │ │ └── styles.css ├── students │ ├── dao.py │ ├── models.py │ ├── rb.py │ ├── router.py │ └── schemas.py ├── templates │ ├── login_form.html │ ├── profile.html │ ├── register_form.html │ ├── student.html │ └── students.html └── users │ ├── auth.py │ ├── dao.py │ ├── dependencies.py │ ├── models.py │ ├── router.py │ └── schemas.py ├── docker-compose.yml ├── requirements.txt └── tests └── test.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Игнорирование виртуальной среды Python 2 | venv/ 3 | .venv/ 4 | 5 | 6 | # Игнорирование файлов с окружением 7 | .env 8 | 9 | # Игнорирование скомпилированных файлов Python 10 | __pycache__/ 11 | **/__pycache__/ 12 | 13 | # Игнорирование настроек проекта для PyCharm 14 | .idea/ 15 | **/.idea/ 16 | -------------------------------------------------------------------------------- /alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts. 5 | # Use forward slashes (/) also on windows to provide an os agnostic path 6 | script_location = app/migration 7 | 8 | # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s 9 | # Uncomment the line below if you want the files to be prepended with date and time 10 | # file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s 11 | 12 | # sys.path path, will be prepended to sys.path if present. 13 | # defaults to the current working directory. 14 | prepend_sys_path = . 15 | 16 | # timezone to use when rendering the date within the migration file 17 | # as well as the filename. 18 | # If specified, requires the python>=3.9 or backports.zoneinfo library. 19 | # Any required deps can installed by adding `alembic[tz]` to the pip requirements 20 | # string value is passed to ZoneInfo() 21 | # leave blank for localtime 22 | # timezone = 23 | 24 | # max length of characters to apply to the "slug" field 25 | # truncate_slug_length = 40 26 | 27 | # set to 'true' to run the environment during 28 | # the 'revision' command, regardless of autogenerate 29 | # revision_environment = false 30 | 31 | # set to 'true' to allow .pyc and .pyo files without 32 | # a source .py file to be detected as revisions in the 33 | # versions/ directory 34 | # sourceless = false 35 | 36 | # version location specification; This defaults 37 | # to migration/versions. When using multiple version 38 | # directories, initial revisions must be specified with --version-path. 39 | # The path separator used here should be the separator specified by "version_path_separator" below. 40 | # version_locations = %(here)s/bar:%(here)s/bat:migration/versions 41 | 42 | # version path separator; As mentioned above, this is the character used to split 43 | # version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. 44 | # If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. 45 | # Valid values for version_path_separator are: 46 | # 47 | # version_path_separator = : 48 | # version_path_separator = ; 49 | # version_path_separator = space 50 | version_path_separator = os # Use os.pathsep. Default configuration used for new projects. 51 | 52 | # set to 'true' to search source files recursively 53 | # in each "version_locations" directory 54 | # new in Alembic version 1.10 55 | # recursive_version_locations = false 56 | 57 | # the output encoding used when revision files 58 | # are written from script.py.mako 59 | # output_encoding = utf-8 60 | 61 | sqlalchemy.url = driver://user:pass@localhost/dbname 62 | 63 | 64 | [post_write_hooks] 65 | # post_write_hooks defines scripts or Python functions that are run 66 | # on newly generated revision scripts. See the documentation for further 67 | # detail and examples 68 | 69 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 70 | # hooks = black 71 | # black.type = console_scripts 72 | # black.entrypoint = black 73 | # black.options = -l 79 REVISION_SCRIPT_FILENAME 74 | 75 | # lint with attempts to fix using "ruff" - use the exec runner, execute a binary 76 | # hooks = ruff 77 | # ruff.type = exec 78 | # ruff.executable = %(here)s/.venv/bin/ruff 79 | # ruff.options = --fix REVISION_SCRIPT_FILENAME 80 | 81 | # Logging configuration 82 | [loggers] 83 | keys = root,sqlalchemy,alembic 84 | 85 | [handlers] 86 | keys = console 87 | 88 | [formatters] 89 | keys = generic 90 | 91 | [logger_root] 92 | level = WARN 93 | handlers = console 94 | qualname = 95 | 96 | [logger_sqlalchemy] 97 | level = WARN 98 | handlers = 99 | qualname = sqlalchemy.engine 100 | 101 | [logger_alembic] 102 | level = INFO 103 | handlers = 104 | qualname = alembic 105 | 106 | [handler_console] 107 | class = StreamHandler 108 | args = (sys.stderr,) 109 | level = NOTSET 110 | formatter = generic 111 | 112 | [formatter_generic] 113 | format = %(levelname)-5.5s [%(name)s] %(message)s 114 | datefmt = %H:%M:%S 115 | -------------------------------------------------------------------------------- /app/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pydantic_settings import BaseSettings, SettingsConfigDict 3 | 4 | 5 | class Settings(BaseSettings): 6 | DB_HOST: str 7 | DB_PORT: int 8 | DB_NAME: str 9 | DB_USER: str 10 | DB_PASSWORD: str 11 | SECRET_KEY: str 12 | ALGORITHM: str 13 | model_config = SettingsConfigDict( 14 | env_file=os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", ".env") 15 | ) 16 | 17 | 18 | settings = Settings() 19 | 20 | 21 | def get_db_url(): 22 | return (f"postgresql+asyncpg://{settings.DB_USER}:{settings.DB_PASSWORD}@" 23 | f"{settings.DB_HOST}:{settings.DB_PORT}/{settings.DB_NAME}") 24 | 25 | 26 | def get_auth_data(): 27 | return {"secret_key": settings.SECRET_KEY, "algorithm": settings.ALGORITHM} 28 | -------------------------------------------------------------------------------- /app/dao/base.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.exc import SQLAlchemyError 2 | from sqlalchemy.future import select 3 | from sqlalchemy import update as sqlalchemy_update, delete as sqlalchemy_delete 4 | from app.database import async_session_maker 5 | 6 | 7 | class BaseDAO: 8 | model = None 9 | 10 | @classmethod 11 | async def find_one_or_none_by_id(cls, data_id: int): 12 | """ 13 | Асинхронно находит и возвращает один экземпляр модели по указанным критериям или None. 14 | 15 | Аргументы: 16 | data_id: Критерии фильтрации в виде идентификатора записи. 17 | 18 | Возвращает: 19 | Экземпляр модели или None, если ничего не найдено. 20 | """ 21 | async with async_session_maker() as session: 22 | query = select(cls.model).filter_by(id=data_id) 23 | result = await session.execute(query) 24 | return result.scalar_one_or_none() 25 | 26 | @classmethod 27 | async def find_one_or_none(cls, **filter_by): 28 | """ 29 | Асинхронно находит и возвращает один экземпляр модели по указанным критериям или None. 30 | 31 | Аргументы: 32 | **filter_by: Критерии фильтрации в виде именованных параметров. 33 | 34 | Возвращает: 35 | Экземпляр модели или None, если ничего не найдено. 36 | """ 37 | async with async_session_maker() as session: 38 | query = select(cls.model).filter_by(**filter_by) 39 | result = await session.execute(query) 40 | return result.scalar_one_or_none() 41 | 42 | @classmethod 43 | async def find_all(cls, **filter_by): 44 | """ 45 | Асинхронно находит и возвращает все экземпляры модели, удовлетворяющие указанным критериям. 46 | 47 | Аргументы: 48 | **filter_by: Критерии фильтрации в виде именованных параметров. 49 | 50 | Возвращает: 51 | Список экземпляров модели. 52 | """ 53 | async with async_session_maker() as session: 54 | query = select(cls.model).filter_by(**filter_by) 55 | result = await session.execute(query) 56 | return result.scalars().all() 57 | 58 | @classmethod 59 | async def add(cls, **values): 60 | """ 61 | Асинхронно создает новый экземпляр модели с указанными значениями. 62 | 63 | Аргументы: 64 | **values: Именованные параметры для создания нового экземпляра модели. 65 | 66 | Возвращает: 67 | Созданный экземпляр модели. 68 | """ 69 | async with async_session_maker() as session: 70 | async with session.begin(): 71 | new_instance = cls.model(**values) 72 | session.add(new_instance) 73 | try: 74 | await session.commit() 75 | except SQLAlchemyError as e: 76 | await session.rollback() 77 | raise e 78 | return new_instance 79 | 80 | @classmethod 81 | async def add_many(cls, instances: list[dict]): 82 | """ 83 | Асинхронно создает несколько новых экземпляров модели с указанными значениями. 84 | 85 | Аргументы: 86 | instances: Список словарей, где каждый словарь содержит именованные параметры для создания нового 87 | экземпляра модели. 88 | 89 | Возвращает: 90 | Список созданных экземпляров модели. 91 | """ 92 | async with async_session_maker() as session: 93 | async with session.begin(): 94 | new_instances = [cls.model(**values) for values in instances] 95 | session.add_all(new_instances) 96 | try: 97 | await session.commit() 98 | except SQLAlchemyError as e: 99 | await session.rollback() 100 | raise e 101 | return new_instances 102 | 103 | @classmethod 104 | async def update(cls, filter_by, **values): 105 | """ 106 | Асинхронно обновляет экземпляры модели, удовлетворяющие критериям фильтрации, указанным в filter_by, 107 | новыми значениями, указанными в values. 108 | 109 | Аргументы: 110 | filter_by: Критерии фильтрации в виде именованных параметров. 111 | **values: Именованные параметры для обновления значений экземпляров модели. 112 | 113 | Возвращает: 114 | Количество обновленных экземпляров модели. 115 | """ 116 | async with async_session_maker() as session: 117 | async with session.begin(): 118 | query = ( 119 | sqlalchemy_update(cls.model) 120 | .where(*[getattr(cls.model, k) == v for k, v in filter_by.items()]) 121 | .values(**values) 122 | .execution_options(synchronize_session="fetch") 123 | ) 124 | result = await session.execute(query) 125 | try: 126 | await session.commit() 127 | except SQLAlchemyError as e: 128 | await session.rollback() 129 | raise e 130 | return result.rowcount 131 | 132 | @classmethod 133 | async def delete(cls, delete_all: bool = False, **filter_by): 134 | """ 135 | Асинхронно удаляет экземпляры модели, удовлетворяющие критериям фильтрации, указанным в filter_by. 136 | 137 | Аргументы: 138 | delete_all: Если True, удаляет все экземпляры модели без фильтрации. 139 | **filter_by: Критерии фильтрации в виде именованных параметров. 140 | 141 | Возвращает: 142 | Количество удаленных экземпляров модели. 143 | """ 144 | if delete_all is False: 145 | if not filter_by: 146 | raise ValueError("Необходимо указать хотя бы один параметр для удаления.") 147 | 148 | async with async_session_maker() as session: 149 | async with session.begin(): 150 | query = sqlalchemy_delete(cls.model).filter_by(**filter_by) 151 | result = await session.execute(query) 152 | try: 153 | await session.commit() 154 | except SQLAlchemyError as e: 155 | await session.rollback() 156 | raise e 157 | return result.rowcount 158 | -------------------------------------------------------------------------------- /app/database.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Annotated 3 | 4 | from sqlalchemy import func 5 | from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncAttrs 6 | from sqlalchemy.orm import DeclarativeBase, declared_attr, Mapped, mapped_column 7 | 8 | from app.config import get_db_url 9 | 10 | DATABASE_URL = get_db_url() 11 | 12 | engine = create_async_engine(DATABASE_URL) 13 | async_session_maker = async_sessionmaker(engine, expire_on_commit=False) 14 | 15 | # настройка аннотаций 16 | int_pk = Annotated[int, mapped_column(primary_key=True)] 17 | created_at = Annotated[datetime, mapped_column(server_default=func.now())] 18 | updated_at = Annotated[datetime, mapped_column(server_default=func.now(), onupdate=datetime.now)] 19 | str_uniq = Annotated[str, mapped_column(unique=True, nullable=False)] 20 | str_null_true = Annotated[str, mapped_column(nullable=True)] 21 | 22 | 23 | class Base(AsyncAttrs, DeclarativeBase): 24 | __abstract__ = True 25 | 26 | @declared_attr.directive 27 | def __tablename__(cls) -> str: 28 | return f"{cls.__name__.lower()}s" 29 | 30 | created_at: Mapped[created_at] 31 | updated_at: Mapped[updated_at] 32 | -------------------------------------------------------------------------------- /app/exceptions.py: -------------------------------------------------------------------------------- 1 | from fastapi import status, HTTPException 2 | 3 | UserAlreadyExistsException = HTTPException(status_code=status.HTTP_409_CONFLICT, 4 | detail='Пользователь уже существует') 5 | 6 | IncorrectEmailOrPasswordException = HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, 7 | detail='Неверная почта или пароль') 8 | 9 | TokenExpiredException = HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, 10 | detail='Токен истек') 11 | 12 | TokenNoFound = HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, 13 | detail='Токен истек') 14 | 15 | NoJwtException = HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, 16 | detail='Токен не валидный!') 17 | 18 | NoUserIdException = HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, 19 | detail='Не найден ID пользователя') 20 | 21 | ForbiddenException = HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='Недостаточно прав!') 22 | -------------------------------------------------------------------------------- /app/main.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | from starlette.requests import Request 3 | from starlette.responses import RedirectResponse 4 | 5 | from app.exceptions import TokenNotFoundException 6 | from app.students.router import router as router_students 7 | from app.majors.router import router as router_majors 8 | from app.users.router import router as router_users 9 | from app.pages.router import router as router_pages 10 | from fastapi.staticfiles import StaticFiles 11 | 12 | app = FastAPI() 13 | 14 | app.mount('/static', StaticFiles(directory='app/static'), 'static') 15 | 16 | 17 | @app.get("/") 18 | def home_page(): 19 | return {"message": "Привет, Хабр!"} 20 | 21 | 22 | 23 | app.include_router(router_users) 24 | app.include_router(router_students) 25 | app.include_router(router_majors) 26 | app.include_router(router_pages) 27 | -------------------------------------------------------------------------------- /app/majors/dao.py: -------------------------------------------------------------------------------- 1 | from app.dao.base import BaseDAO 2 | from app.majors.models import Major 3 | 4 | 5 | class MajorsDAO(BaseDAO): 6 | model = Major -------------------------------------------------------------------------------- /app/majors/models.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import text 2 | from sqlalchemy.orm import Mapped, mapped_column, relationship 3 | from app.database import Base, str_uniq, int_pk, str_null_true 4 | 5 | 6 | # создаем модель таблицы факультетов (majors) 7 | class Major(Base): 8 | id: Mapped[int_pk] 9 | major_name: Mapped[str_uniq] 10 | major_description: Mapped[str_null_true] 11 | count_students: Mapped[int] = mapped_column(server_default=text('0')) 12 | 13 | # Определяем отношения: один факультет может иметь много студентов 14 | students: Mapped[list["Student"]] = relationship("Student", back_populates="major") 15 | extend_existing = True 16 | 17 | def __str__(self): 18 | return f"{self.__class__.__name__}(id={self.id}, major_name={self.major_name!r})" 19 | 20 | def __repr__(self): 21 | return str(self) 22 | -------------------------------------------------------------------------------- /app/majors/router.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | from app.majors.dao import MajorsDAO 3 | from app.majors.schemas import SMajorsAdd, SMajorsUpdDesc 4 | 5 | router = APIRouter(prefix='/majors', tags=['Работа с факультетами']) 6 | 7 | 8 | @router.post("/add/") 9 | async def add_major(major: SMajorsAdd) -> dict: 10 | check = await MajorsDAO.add(**major.dict()) 11 | if check: 12 | return {"message": "Факультет успешно добавлен!", "major": major} 13 | else: 14 | return {"message": "Ошибка при добавлении факультета!"} 15 | 16 | 17 | @router.put("/update_description/") 18 | async def update_major(major: SMajorsUpdDesc) -> dict: 19 | check = await MajorsDAO.update(filter_by={'major_name': major.major_name}, 20 | major_description=major.major_description) 21 | if check: 22 | return {"message": "Описание факультета успешно обновлено!", "major": major} 23 | else: 24 | return {"message": "Ошибка при обновлении описания факультета!"} 25 | 26 | 27 | @router.delete("/delete/{major_id}") 28 | async def delete_major(major_id: int) -> dict: 29 | check = await MajorsDAO.delete(id=major_id) 30 | if check: 31 | return {"message": f"Факультет с ID {major_id} удален!"} 32 | else: 33 | return {"message": "Ошибка при удалении факультета!"} 34 | -------------------------------------------------------------------------------- /app/majors/schemas.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, Field 2 | 3 | 4 | class SMajorsAdd(BaseModel): 5 | major_name: str = Field(..., description="Название факультета") 6 | major_description: str = Field(None, description="Описание факультета") 7 | count_students: int = Field(0, description="Количество студентов") 8 | 9 | 10 | class SMajorsUpdDesc(BaseModel): 11 | major_name: str = Field(..., description="Название факультета") 12 | major_description: str = Field(None, description="Новое описание факультета") -------------------------------------------------------------------------------- /app/migration/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration with an async dbapi. -------------------------------------------------------------------------------- /app/migration/env.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from logging.config import fileConfig 3 | from sqlalchemy import pool 4 | from sqlalchemy.engine import Connection 5 | from sqlalchemy.ext.asyncio import async_engine_from_config 6 | from alembic import context 7 | 8 | import sys 9 | from os.path import dirname, abspath 10 | 11 | sys.path.insert(0, dirname(dirname(abspath(__file__)))) 12 | 13 | from app.database import DATABASE_URL, Base 14 | from app.students.models import Student 15 | from app.majors.models import Major 16 | from app.users.models import User 17 | 18 | 19 | # this is the Alembic Config object, which provides 20 | # access to the values within the .ini file in use. 21 | config = context.config 22 | config.set_main_option("sqlalchemy.url", DATABASE_URL) 23 | # Interpret the config file for Python logging. 24 | # This line sets up loggers basically. 25 | if config.config_file_name is not None: 26 | fileConfig(config.config_file_name) 27 | 28 | # add your model's MetaData object here 29 | # for 'autogenerate' support 30 | # from myapp import mymodel 31 | # target_metadata = mymodel.Base.metadata 32 | target_metadata = Base.metadata 33 | 34 | 35 | # other values from the config, defined by the needs of env.py, 36 | # can be acquired: 37 | # my_important_option = config.get_main_option("my_important_option") 38 | # ... etc. 39 | 40 | 41 | def run_migrations_offline() -> None: 42 | """Run migrations in 'offline' mode. 43 | 44 | This configures the context with just a URL 45 | and not an Engine, though an Engine is acceptable 46 | here as well. By skipping the Engine creation 47 | we don't even need a DBAPI to be available. 48 | 49 | Calls to context.execute() here emit the given string to the 50 | script output. 51 | 52 | """ 53 | url = config.get_main_option("sqlalchemy.url") 54 | context.configure( 55 | url=url, 56 | target_metadata=target_metadata, 57 | literal_binds=True, 58 | dialect_opts={"paramstyle": "named"}, 59 | ) 60 | 61 | with context.begin_transaction(): 62 | context.run_migrations() 63 | 64 | 65 | def do_run_migrations(connection: Connection) -> None: 66 | context.configure(connection=connection, target_metadata=target_metadata) 67 | 68 | with context.begin_transaction(): 69 | context.run_migrations() 70 | 71 | 72 | async def run_async_migrations() -> None: 73 | """In this scenario we need to create an Engine 74 | and associate a connection with the context. 75 | 76 | """ 77 | 78 | connectable = async_engine_from_config( 79 | config.get_section(config.config_ini_section, {}), 80 | prefix="sqlalchemy.", 81 | poolclass=pool.NullPool, 82 | ) 83 | 84 | async with connectable.connect() as connection: 85 | await connection.run_sync(do_run_migrations) 86 | 87 | await connectable.dispose() 88 | 89 | 90 | def run_migrations_online() -> None: 91 | """Run migrations in 'online' mode.""" 92 | 93 | asyncio.run(run_async_migrations()) 94 | 95 | 96 | if context.is_offline_mode(): 97 | run_migrations_offline() 98 | else: 99 | run_migrations_online() 100 | -------------------------------------------------------------------------------- /app/migration/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from typing import Sequence, Union 9 | 10 | from alembic import op 11 | import sqlalchemy as sa 12 | ${imports if imports else ""} 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = ${repr(up_revision)} 16 | down_revision: Union[str, None] = ${repr(down_revision)} 17 | branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} 18 | depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} 19 | 20 | 21 | def upgrade() -> None: 22 | ${upgrades if upgrades else "pass"} 23 | 24 | 25 | def downgrade() -> None: 26 | ${downgrades if downgrades else "pass"} 27 | -------------------------------------------------------------------------------- /app/migration/versions/3bc793e24f23_add.py: -------------------------------------------------------------------------------- 1 | """add 2 | 3 | Revision ID: 3bc793e24f23 4 | Revises: 3e41a2ab1fca 5 | Create Date: 2024-07-23 13:42:46.733375 6 | 7 | """ 8 | from typing import Sequence, Union 9 | 10 | from alembic import op 11 | import sqlalchemy as sa 12 | 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = '3bc793e24f23' 16 | down_revision: Union[str, None] = '3e41a2ab1fca' 17 | branch_labels: Union[str, Sequence[str], None] = None 18 | depends_on: Union[str, Sequence[str], None] = None 19 | 20 | 21 | def upgrade() -> None: 22 | pass 23 | 24 | 25 | def downgrade() -> None: 26 | pass 27 | -------------------------------------------------------------------------------- /app/migration/versions/3e41a2ab1fca_create_instruction_table.py: -------------------------------------------------------------------------------- 1 | """create instruction table 2 | 3 | Revision ID: 3e41a2ab1fca 4 | Revises: 5 | Create Date: 2024-07-23 13:42:41.179955 6 | 7 | """ 8 | from typing import Sequence, Union 9 | 10 | from alembic import op 11 | import sqlalchemy as sa 12 | 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = '3e41a2ab1fca' 16 | down_revision: Union[str, None] = None 17 | branch_labels: Union[str, Sequence[str], None] = None 18 | depends_on: Union[str, Sequence[str], None] = None 19 | 20 | 21 | def upgrade() -> None: 22 | # ### commands auto generated by Alembic - please adjust! ### 23 | op.create_table('majors', 24 | sa.Column('id', sa.Integer(), nullable=False), 25 | sa.Column('major_name', sa.String(), nullable=False), 26 | sa.Column('major_description', sa.String(), nullable=True), 27 | sa.Column('count_students', sa.Integer(), server_default=sa.text('0'), nullable=False), 28 | sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), 29 | sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), 30 | sa.PrimaryKeyConstraint('id'), 31 | sa.UniqueConstraint('major_name') 32 | ) 33 | op.create_table('users', 34 | sa.Column('id', sa.Integer(), nullable=False), 35 | sa.Column('phone_number', sa.String(), nullable=False), 36 | sa.Column('first_name', sa.String(), nullable=False), 37 | sa.Column('last_name', sa.String(), nullable=False), 38 | sa.Column('email', sa.String(), nullable=False), 39 | sa.Column('password', sa.String(), nullable=False), 40 | sa.Column('is_user', sa.Boolean(), server_default=sa.text('true'), nullable=False), 41 | sa.Column('is_student', sa.Boolean(), server_default=sa.text('false'), nullable=False), 42 | sa.Column('is_teacher', sa.Boolean(), server_default=sa.text('false'), nullable=False), 43 | sa.Column('is_admin', sa.Boolean(), server_default=sa.text('false'), nullable=False), 44 | sa.Column('is_super_admin', sa.Boolean(), server_default=sa.text('false'), nullable=False), 45 | sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), 46 | sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), 47 | sa.PrimaryKeyConstraint('id'), 48 | sa.UniqueConstraint('email'), 49 | sa.UniqueConstraint('phone_number') 50 | ) 51 | op.create_table('students', 52 | sa.Column('id', sa.Integer(), nullable=False), 53 | sa.Column('phone_number', sa.String(), nullable=False), 54 | sa.Column('first_name', sa.String(), nullable=False), 55 | sa.Column('last_name', sa.String(), nullable=False), 56 | sa.Column('date_of_birth', sa.Date(), nullable=False), 57 | sa.Column('email', sa.String(), nullable=False), 58 | sa.Column('address', sa.Text(), nullable=False), 59 | sa.Column('enrollment_year', sa.Integer(), nullable=False), 60 | sa.Column('course', sa.Integer(), nullable=False), 61 | sa.Column('special_notes', sa.String(), nullable=True), 62 | sa.Column('major_id', sa.Integer(), nullable=False), 63 | sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), 64 | sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), 65 | sa.ForeignKeyConstraint(['major_id'], ['majors.id'], ), 66 | sa.PrimaryKeyConstraint('id'), 67 | sa.UniqueConstraint('email'), 68 | sa.UniqueConstraint('phone_number') 69 | ) 70 | # ### end Alembic commands ### 71 | 72 | 73 | def downgrade() -> None: 74 | # ### commands auto generated by Alembic - please adjust! ### 75 | op.drop_table('students') 76 | op.drop_table('users') 77 | op.drop_table('majors') 78 | # ### end Alembic commands ### 79 | -------------------------------------------------------------------------------- /app/migration/versions/c9bb7d635f6d_add_column_photo.py: -------------------------------------------------------------------------------- 1 | """add column photo 2 | 3 | Revision ID: c9bb7d635f6d 4 | Revises: 3bc793e24f23 5 | Create Date: 2024-07-23 15:47:03.867344 6 | 7 | """ 8 | from typing import Sequence, Union 9 | 10 | from alembic import op 11 | import sqlalchemy as sa 12 | 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = 'c9bb7d635f6d' 16 | down_revision: Union[str, None] = '3bc793e24f23' 17 | branch_labels: Union[str, Sequence[str], None] = None 18 | depends_on: Union[str, Sequence[str], None] = None 19 | 20 | 21 | def upgrade() -> None: 22 | # ### commands auto generated by Alembic - please adjust! ### 23 | op.add_column('students', sa.Column('photo', sa.String(), nullable=True)) 24 | # ### end Alembic commands ### 25 | 26 | 27 | def downgrade() -> None: 28 | # ### commands auto generated by Alembic - please adjust! ### 29 | op.drop_column('students', 'photo') 30 | # ### end Alembic commands ### 31 | -------------------------------------------------------------------------------- /app/pages/router.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Request, Depends, UploadFile 2 | from fastapi.templating import Jinja2Templates 3 | import shutil 4 | 5 | from app.students.router import get_all_students, get_student_by_id 6 | from app.users.router import get_me 7 | 8 | router = APIRouter(prefix='/pages', tags=['Фронтенд']) 9 | templates = Jinja2Templates(directory='app/templates') 10 | 11 | 12 | @router.get('/students') 13 | async def get_students_html(request: Request, student=Depends(get_all_students)): 14 | return templates.TemplateResponse(name='students.html', 15 | context={'request': request, 'students': student}) 16 | 17 | 18 | @router.get('/profile') 19 | async def get_my_profile(request: Request, profile=Depends(get_me)): 20 | return templates.TemplateResponse(name='profile.html', context={'request': request, 'profile': profile}) 21 | 22 | 23 | @router.get('/register') 24 | async def get_students_html(request: Request): 25 | return templates.TemplateResponse(name='register_form.html', context={'request': request}) 26 | 27 | 28 | @router.get('/login') 29 | async def get_students_html(request: Request): 30 | return templates.TemplateResponse(name='login_form.html', context={'request': request}) 31 | 32 | 33 | @router.get('/students/{student_id}') 34 | async def get_students_html(request: Request, student=Depends(get_student_by_id)): 35 | return templates.TemplateResponse(name='student.html', 36 | context={'request': request, 'student': student}) 37 | 38 | 39 | @router.post('/add_photo') 40 | async def add_student_photo(file: UploadFile, image_name: int): 41 | with open(f"app/static/images/{image_name}.webp", "wb+") as photo_obj: 42 | shutil.copyfileobj(file.file, photo_obj) 43 | -------------------------------------------------------------------------------- /app/static/images/2.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yakvenalex/FastApiLern/48b7385883af8b4823444c3430c5be92dceb7037/app/static/images/2.webp -------------------------------------------------------------------------------- /app/static/images/3.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yakvenalex/FastApiLern/48b7385883af8b4823444c3430c5be92dceb7037/app/static/images/3.webp -------------------------------------------------------------------------------- /app/static/images/4.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yakvenalex/FastApiLern/48b7385883af8b4823444c3430c5be92dceb7037/app/static/images/4.webp -------------------------------------------------------------------------------- /app/static/js/script.js: -------------------------------------------------------------------------------- 1 | async function regFunction(event) { 2 | event.preventDefault(); // Предотвращаем стандартное действие формы 3 | 4 | // Получаем форму и собираем данные из неё 5 | const form = document.getElementById('registration-form'); 6 | const formData = new FormData(form); 7 | const data = Object.fromEntries(formData.entries()); 8 | 9 | try { 10 | const response = await fetch('/auth/register', { 11 | method: 'POST', 12 | headers: { 13 | 'Content-Type': 'application/json' 14 | }, 15 | body: JSON.stringify(data) 16 | }); 17 | 18 | // Проверяем успешность ответа 19 | if (!response.ok) { 20 | // Получаем данные об ошибке 21 | const errorData = await response.json(); 22 | displayErrors(errorData); // Отображаем ошибки 23 | return; // Прерываем выполнение функции 24 | } 25 | 26 | const result = await response.json(); 27 | 28 | if (result.message) { // Проверяем наличие сообщения о успешной регистрации 29 | window.location.href = '/pages/login'; // Перенаправляем пользователя на страницу логина 30 | } else { 31 | alert(result.message || 'Неизвестная ошибка'); 32 | } 33 | } catch (error) { 34 | console.error('Ошибка:', error); 35 | alert('Произошла ошибка при регистрации. Пожалуйста, попробуйте снова.'); 36 | } 37 | } 38 | 39 | async function loginFunction(event) { 40 | event.preventDefault(); // Предотвращаем стандартное действие формы 41 | 42 | // Получаем форму и собираем данные из неё 43 | const form = document.getElementById('login-form'); 44 | const formData = new FormData(form); 45 | const data = Object.fromEntries(formData.entries()); 46 | 47 | try { 48 | const response = await fetch('/auth/login', { 49 | method: 'POST', 50 | headers: { 51 | 'Content-Type': 'application/json' 52 | }, 53 | body: JSON.stringify(data) 54 | }); 55 | 56 | // Проверяем успешность ответа 57 | if (!response.ok) { 58 | // Получаем данные об ошибке 59 | const errorData = await response.json(); 60 | displayErrors(errorData); // Отображаем ошибки 61 | return; // Прерываем выполнение функции 62 | } 63 | 64 | const result = await response.json(); 65 | 66 | if (result.message) { // Проверяем наличие сообщения о успешной регистрации 67 | window.location.href = '/pages/profile'; // Перенаправляем пользователя на страницу логина 68 | } else { 69 | alert(result.message || 'Неизвестная ошибка'); 70 | } 71 | } catch (error) { 72 | console.error('Ошибка:', error); 73 | alert('Произошла ошибка при входе. Пожалуйста, попробуйте снова.'); 74 | } 75 | } 76 | 77 | 78 | async function logoutFunction() { 79 | try { 80 | // Отправка POST-запроса для удаления куки на сервере 81 | let response = await fetch('/auth/logout', { 82 | method: 'POST', 83 | headers: { 84 | 'Content-Type': 'application/json' 85 | } 86 | }); 87 | 88 | // Проверка ответа сервера 89 | if (response.ok) { 90 | // Перенаправляем пользователя на страницу логина 91 | window.location.href = '/pages/login'; 92 | } else { 93 | // Чтение возможного сообщения об ошибке от сервера 94 | const errorData = await response.json(); 95 | console.error('Ошибка при выходе:', errorData.message || response.statusText); 96 | } 97 | } catch (error) { 98 | console.error('Ошибка сети', error); 99 | } 100 | } 101 | 102 | 103 | // Функция для отображения ошибок 104 | function displayErrors(errorData) { 105 | let message = 'Произошла ошибка'; 106 | 107 | if (errorData && errorData.detail) { 108 | if (Array.isArray(errorData.detail)) { 109 | // Обработка массива ошибок 110 | message = errorData.detail.map(error => { 111 | if (error.type === 'string_too_short') { 112 | return `Поле "${error.loc[1]}" должно содержать минимум ${error.ctx.min_length} символов.`; 113 | } 114 | return error.msg || 'Произошла ошибка'; 115 | }).join('\n'); 116 | } else { 117 | // Обработка одиночной ошибки 118 | message = errorData.detail || 'Произошла ошибка'; 119 | } 120 | } 121 | 122 | // Отображение сообщения об ошибке 123 | alert(message); 124 | } -------------------------------------------------------------------------------- /app/static/style/register.css: -------------------------------------------------------------------------------- 1 | /* Общие стили для страницы */ 2 | body { 3 | font-family: Arial, sans-serif; 4 | background-color: #f4f4f4; 5 | margin: 0; 6 | padding: 0; 7 | } 8 | 9 | /* Контейнер для формы */ 10 | .container { 11 | max-width: 500px; 12 | margin: 50px auto; 13 | padding: 20px; 14 | background-color: #fff; 15 | border-radius: 8px; 16 | box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); 17 | } 18 | 19 | /* Заголовок страницы */ 20 | h1 { 21 | margin-top: 0; 22 | color: #333; 23 | } 24 | 25 | /* Стили формы */ 26 | .registration-form { 27 | display: flex; 28 | flex-direction: column; 29 | } 30 | 31 | /* Поля ввода */ 32 | .registration-form label { 33 | margin-bottom: 5px; 34 | font-weight: bold; 35 | } 36 | 37 | .registration-form input[type="email"], 38 | .registration-form input[type="password"], 39 | .registration-form input[type="tel"], 40 | .registration-form input[type="text"] { 41 | padding: 10px; 42 | margin-bottom: 15px; 43 | border: 1px solid #ccc; 44 | border-radius: 4px; 45 | font-size: 16px; 46 | } 47 | 48 | /* Кнопка отправки */ 49 | .submit-button { 50 | padding: 10px 20px; 51 | border: none; 52 | background-color: #007bff; 53 | color: white; 54 | border-radius: 4px; 55 | font-size: 16px; 56 | cursor: pointer; 57 | } 58 | 59 | .submit-button:hover { 60 | background-color: #0056b3; 61 | } 62 | -------------------------------------------------------------------------------- /app/static/style/student.css: -------------------------------------------------------------------------------- 1 | /* Файл: static/style/student.css */ 2 | 3 | body { 4 | font-family: Arial, sans-serif; 5 | margin: 0; 6 | padding: 20px; 7 | background-color: #f5f5f5; 8 | } 9 | 10 | .container { 11 | max-width: 800px; 12 | margin: 0 auto; 13 | padding: 20px; 14 | background-color: #fff; 15 | border-radius: 8px; 16 | box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); 17 | } 18 | 19 | h1 { 20 | font-size: 24px; 21 | color: #333; 22 | margin-bottom: 20px; 23 | } 24 | 25 | .student-profile { 26 | display: flex; 27 | align-items: center; 28 | border-bottom: 1px solid #ddd; 29 | padding-bottom: 20px; 30 | margin-bottom: 20px; 31 | } 32 | 33 | .student-profile img { 34 | width: 150px; 35 | height: 150px; 36 | object-fit: cover; 37 | border-radius: 50%; 38 | margin-right: 20px; 39 | } 40 | 41 | .student-info { 42 | flex-grow: 1; 43 | } 44 | 45 | .student-info h2 { 46 | margin: 0; 47 | font-size: 22px; 48 | color: #333; 49 | } 50 | 51 | .student-info p { 52 | margin: 5px 0; 53 | font-size: 16px; 54 | color: #555; 55 | } 56 | 57 | .back-link { 58 | display: inline-block; 59 | padding: 10px 20px; 60 | border: none; 61 | background-color: #007bff; 62 | color: white; 63 | border-radius: 5px; 64 | text-decoration: none; 65 | margin-top: 20px; 66 | } 67 | 68 | .back-link:hover { 69 | background-color: #0056b3; 70 | } 71 | 72 | .submit-button { 73 | padding: 10px 20px; 74 | border: none; 75 | background-color: #007bff; 76 | color: white; 77 | border-radius: 4px; 78 | font-size: 16px; 79 | cursor: pointer; 80 | } 81 | 82 | .submit-button:hover { 83 | background-color: #0056b3; 84 | } -------------------------------------------------------------------------------- /app/static/style/styles.css: -------------------------------------------------------------------------------- 1 | .student-card { 2 | border: 1px solid #ddd; 3 | padding: 10px; 4 | margin-bottom: 20px; 5 | border-radius: 5px; 6 | display: flex; 7 | align-items: center; 8 | } 9 | 10 | .student-card img { 11 | width: 100px; 12 | height: 100px; 13 | object-fit: cover; 14 | border-radius: 50%; 15 | margin-right: 15px; 16 | } 17 | 18 | .student-card .details { 19 | flex-grow: 1; 20 | } 21 | 22 | .student-card a { 23 | display: inline-block; 24 | padding: 10px 20px; 25 | border: none; 26 | background-color: #007bff; 27 | color: white; 28 | border-radius: 5px; 29 | text-decoration: none; 30 | } 31 | 32 | .student-card a:hover { 33 | background-color: #0056b3; 34 | } -------------------------------------------------------------------------------- /app/students/dao.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import update, event, delete 2 | from sqlalchemy.future import select 3 | from sqlalchemy.orm import joinedload 4 | from app.dao.base import BaseDAO 5 | from app.majors.models import Major 6 | from app.students.models import Student 7 | from app.database import async_session_maker 8 | 9 | 10 | @event.listens_for(Student, 'after_insert') 11 | def receive_after_insert(mapper, connection, target): 12 | major_id = target.major_id 13 | connection.execute( 14 | update(Major) 15 | .where(Major.id == major_id) 16 | .values(count_students=Major.count_students + 1) 17 | ) 18 | 19 | 20 | @event.listens_for(Student, 'after_delete') 21 | def receive_after_delete(mapper, connection, target): 22 | major_id = target.major_id 23 | connection.execute( 24 | update(Major) 25 | .where(Major.id == major_id) 26 | .values(count_students=Major.count_students - 1) 27 | ) 28 | 29 | 30 | class StudentDAO(BaseDAO): 31 | model = Student 32 | 33 | @classmethod 34 | async def find_students(cls, **student_data): 35 | async with async_session_maker() as session: 36 | # Создайте запрос с фильтрацией по параметрам student_data 37 | query = select(cls.model).options(joinedload(cls.model.major)).filter_by(**student_data) 38 | result = await session.execute(query) 39 | students_info = result.scalars().all() 40 | 41 | # Преобразуйте данные студентов в словари с информацией о специальности 42 | students_data = [] 43 | for student in students_info: 44 | student_dict = student.to_dict() 45 | student_dict['major'] = student.major.major_name if student.major else None 46 | students_data.append(student_dict) 47 | 48 | return students_data 49 | 50 | @classmethod 51 | async def find_full_data(cls, student_id): 52 | async with async_session_maker() as session: 53 | # Query to get student info along with major info 54 | query = select(cls.model).options(joinedload(cls.model.major)).filter_by(id=student_id) 55 | result = await session.execute(query) 56 | student_info = result.scalar_one_or_none() 57 | 58 | # If student is not found, return None 59 | if not student_info: 60 | return None 61 | 62 | student_data = student_info.to_dict() 63 | student_data['major'] = student_info.major.major_name 64 | return student_data 65 | 66 | @classmethod 67 | async def add_student(cls, **student_data: dict): 68 | async with async_session_maker() as session: 69 | async with session.begin(): 70 | new_student = cls.model(**student_data) 71 | session.add(new_student) 72 | await session.flush() 73 | new_student_id = new_student.id 74 | await session.commit() 75 | return new_student_id 76 | 77 | @classmethod 78 | async def delete_student_by_id(cls, student_id: int): 79 | async with async_session_maker() as session: 80 | async with session.begin(): 81 | query = select(cls.model).filter_by(id=student_id) 82 | result = await session.execute(query) 83 | student_to_delete = result.scalar_one_or_none() 84 | 85 | if not student_to_delete: 86 | return None 87 | 88 | # Delete the student 89 | await session.execute( 90 | delete(cls.model).filter_by(id=student_id) 91 | ) 92 | 93 | await session.commit() 94 | return student_id 95 | -------------------------------------------------------------------------------- /app/students/models.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.orm import relationship 2 | from sqlalchemy import ForeignKey, text, Text 3 | from sqlalchemy.orm import Mapped, mapped_column 4 | from app.database import Base, str_uniq, int_pk, str_null_true 5 | from datetime import date 6 | 7 | 8 | # создаем модель таблицы студентов 9 | class Student(Base): 10 | id: Mapped[int_pk] 11 | phone_number: Mapped[str_uniq] 12 | first_name: Mapped[str] 13 | last_name: Mapped[str] 14 | date_of_birth: Mapped[date] 15 | email: Mapped[str_uniq] 16 | address: Mapped[str] = mapped_column(Text, nullable=False) 17 | enrollment_year: Mapped[int] 18 | course: Mapped[int] 19 | photo: Mapped[str] = mapped_column(Text, nullable=True) 20 | special_notes: Mapped[str_null_true] 21 | major_id: Mapped[int] = mapped_column(ForeignKey("majors.id"), nullable=False) 22 | 23 | # Определяем отношения: один студент имеет один факультет 24 | major: Mapped["Major"] = relationship("Major", back_populates="students") 25 | extend_existing = True 26 | 27 | def __str__(self): 28 | return (f"{self.__class__.__name__}(id={self.id}, " 29 | f"first_name={self.first_name!r}," 30 | f"last_name={self.last_name!r})") 31 | 32 | def __repr__(self): 33 | return str(self) 34 | 35 | def to_dict(self): 36 | return { 37 | "id": self.id, 38 | "phone_number": self.phone_number, 39 | "first_name": self.first_name, 40 | "last_name": self.last_name, 41 | "date_of_birth": self.date_of_birth, 42 | "email": self.email, 43 | "address": self.address, 44 | "enrollment_year": self.enrollment_year, 45 | "course": self.course, 46 | "special_notes": self.special_notes, 47 | "major_id": self.major_id, 48 | 'photo': self.photo 49 | } -------------------------------------------------------------------------------- /app/students/rb.py: -------------------------------------------------------------------------------- 1 | class RBStudent: 2 | def __init__(self, student_id: int | None = None, 3 | course: int | None = None, 4 | major_id: int | None = None, 5 | enrollment_year: int | None = None): 6 | self.id = student_id 7 | self.course = course 8 | self.major_id = major_id 9 | self.enrollment_year = enrollment_year 10 | 11 | 12 | def to_dict(self) -> dict: 13 | return {key: value for key, value in { 14 | 'id': self.id, 15 | 'course': self.course, 16 | 'major_id': self.major_id, 17 | 'enrollment_year': self.enrollment_year 18 | }.items() if value is not None} -------------------------------------------------------------------------------- /app/students/router.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends 2 | from app.students.dao import StudentDAO 3 | from app.students.rb import RBStudent 4 | from app.students.schemas import SStudent, SStudentAdd 5 | 6 | router = APIRouter(prefix='/students', tags=['Работа со студентами']) 7 | 8 | 9 | @router.get("/", summary="Получить всех студентов") 10 | async def get_all_students(request_body: RBStudent = Depends()) -> list[SStudent]: 11 | return await StudentDAO.find_students(**request_body.to_dict()) 12 | 13 | 14 | @router.get("/{student_id}", summary="Получить одного студента по id") 15 | async def get_student_by_id(student_id: int) -> SStudent | dict: 16 | rez = await StudentDAO.find_full_data(student_id=student_id) 17 | if rez is None: 18 | return {'message': f'Студент с ID {student_id} не найден!'} 19 | return rez 20 | 21 | 22 | @router.get("/by_filter", summary="Получить одного студента по фильтру") 23 | async def get_student_by_filter(request_body: RBStudent = Depends()) -> SStudent | dict: 24 | rez = await StudentDAO.find_one_or_none(**request_body.to_dict()) 25 | if rez is None: 26 | return {'message': f'Студент с указанными вами параметрами не найден!'} 27 | return rez 28 | 29 | 30 | @router.post("/add/") 31 | async def add_student(student: SStudentAdd) -> dict: 32 | check = await StudentDAO.add_student(**student.dict()) 33 | if check: 34 | return {"message": "Студент успешно добавлен!", "student": student} 35 | else: 36 | return {"message": "Ошибка при добавлении студента!"} 37 | 38 | 39 | @router.delete("/dell/{student_id}") 40 | async def dell_student_by_id(student_id: int) -> dict: 41 | check = await StudentDAO.delete_student_by_id(student_id=student_id) 42 | if check: 43 | return {"message": f"Студент с ID {student_id} удален!"} 44 | else: 45 | return {"message": "Ошибка при удалении студента!"} 46 | -------------------------------------------------------------------------------- /app/students/schemas.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, date 2 | from typing import Optional 3 | import re 4 | 5 | from pydantic import BaseModel, Field, field_validator, EmailStr, ConfigDict 6 | 7 | 8 | class SStudent(BaseModel): 9 | model_config = ConfigDict(from_attributes=True) 10 | id: int 11 | phone_number: str = Field(..., description="Номер телефона в международном формате, начинающийся с '+'") 12 | first_name: str = Field(..., min_length=1, max_length=50, description="Имя студента, от 1 до 50 символов") 13 | last_name: str = Field(..., min_length=1, max_length=50, 14 | description="Фамилия студента, от 1 до 50 символов") 15 | date_of_birth: date = Field(..., description="Дата рождения студента в формате ГГГГ-ММ-ДД") 16 | email: EmailStr = Field(..., description="Электронная почта студента") 17 | address: str = Field(..., min_length=10, max_length=200, 18 | description="Адрес студента, не более 200 символов") 19 | enrollment_year: int = Field(..., ge=2002, description="Год поступления должен быть не меньше 2002") 20 | major_id: int = Field(..., ge=1, description="ID специальности студента") 21 | course: int = Field(..., ge=1, le=5, description="Курс должен быть в диапазоне от 1 до 5") 22 | special_notes: Optional[str] = Field(None, max_length=500, 23 | description="Дополнительные заметки, не более 500 символов") 24 | photo: Optional[str] = Field(None, max_length=100, description="Фото студента") 25 | major: Optional[str] = Field(..., description="Название факультета") 26 | 27 | @field_validator("phone_number") 28 | @classmethod 29 | def validate_phone_number(cls, values: str) -> str: 30 | if not re.match(r'^\+\d{1,15}$', values): 31 | raise ValueError('Номер телефона должен начинаться с "+" и содержать от 1 до 15 цифр') 32 | return values 33 | 34 | @field_validator("date_of_birth") 35 | @classmethod 36 | def validate_date_of_birth(cls, values: date): 37 | if values and values >= datetime.now().date(): 38 | raise ValueError('Дата рождения должна быть в прошлом') 39 | return values 40 | 41 | 42 | class SStudentAdd(BaseModel): 43 | phone_number: str = Field(..., description="Номер телефона в международном формате, начинающийся с '+'") 44 | first_name: str = Field(..., min_length=3, max_length=50, description="Имя студента, от 3 до 50 символов") 45 | last_name: str = Field(..., min_length=3, max_length=50, 46 | description="Фамилия студента, от 3 до 50 символов") 47 | date_of_birth: date = Field(..., description="Дата рождения студента в формате ГГГГ-ММ-ДД") 48 | email: EmailStr = Field(..., description="Электронная почта студента") 49 | address: str = Field(..., min_length=10, max_length=200, 50 | description="Адрес студента, не более 200 символов") 51 | enrollment_year: int = Field(..., ge=2002, description="Год поступления должен быть не меньше 2002") 52 | major_id: int = Field(..., ge=1, description="ID специальности студента") 53 | course: int = Field(..., ge=1, le=5, description="Курс должен быть в диапазоне от 1 до 5") 54 | special_notes: Optional[str] = Field(None, max_length=500, 55 | description="Дополнительные заметки, не более 500 символов") 56 | 57 | @field_validator("phone_number") 58 | @classmethod 59 | def validate_phone_number(cls, values: str) -> str: 60 | if not re.match(r'^\+\d{1,15}$', values): 61 | raise ValueError('Номер телефона должен начинаться с "+" и содержать от 1 до 15 цифр') 62 | return values 63 | 64 | @field_validator("date_of_birth") 65 | @classmethod 66 | def validate_date_of_birth(cls, values: date): 67 | if values and values >= datetime.now().date(): 68 | raise ValueError('Дата рождения должна быть в прошлом') 69 | return values 70 | -------------------------------------------------------------------------------- /app/templates/login_form.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | 6 |Полное имя: {{ profile.first_name }} {{ profile.last_name }}
16 |Email: {{ profile.email }}
17 |Пароль в хэше: {{ profile.password }}
18 |Полное имя: {{ student.first_name }} {{ student.last_name }}
17 |Email: {{ student.email }}
18 |Дата рождения: {{ student.date_of_birth }}
19 |Адрес: {{ student.address }}
20 |Год поступления: {{ student.enrollment_year }}
21 |Курс: {{ student.course }}
22 |Специальность: {{ student.major }}
23 |Специальные заметки: {{ student.special_notes }}
24 |Полное имя: {{ student.first_name }} {{ student.last_name }}
18 |Email: {{ student.email }}
19 |Специальность: {{ student.major }}
20 |Курс: {{ student.course }}
21 | 22 |