diff --git a/backend/app/api/routes/login.py b/backend/app/api/routes/login.py index 964f5fe..980c66f 100644 --- a/backend/app/api/routes/login.py +++ b/backend/app/api/routes/login.py @@ -11,7 +11,7 @@ from app.core import security from app.core.config import settings from app.core.security import get_password_hash from app.models import Message, NewPassword, Token, UserPublic -from app.utils.common import ( +from app.utils import ( generate_password_reset_token, generate_reset_password_email, send_email, diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py index 71fd85d..6429818 100644 --- a/backend/app/api/routes/users.py +++ b/backend/app/api/routes/users.py @@ -24,7 +24,7 @@ from app.models import ( UserUpdate, UserUpdateMe, ) -from app.utils.common import generate_new_account_email, send_email +from app.utils import generate_new_account_email, send_email router = APIRouter(prefix="/users", tags=["users"]) diff --git a/backend/app/api/routes/utils.py b/backend/app/api/routes/utils.py index 9977c03..fc09341 100644 --- a/backend/app/api/routes/utils.py +++ b/backend/app/api/routes/utils.py @@ -3,7 +3,7 @@ from pydantic.networks import EmailStr from app.api.deps import get_current_active_superuser from app.models import Message -from app.utils.common import generate_test_email, send_email +from app.utils import generate_test_email, send_email router = APIRouter(prefix="/utils", tags=["utils"]) diff --git a/backend/app/api/v1/api.py b/backend/app/api/v1/api.py new file mode 100644 index 0000000..7b6461d --- /dev/null +++ b/backend/app/api/v1/api.py @@ -0,0 +1,9 @@ +from fastapi import APIRouter + +from app.api.v1.endpoints import auth, users, utils, todos + +api_router = APIRouter() +api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) +api_router.include_router(users.router, prefix="/users", tags=["users"]) +api_router.include_router(utils.router, prefix="/utils", tags=["utils"]) +api_router.include_router(todos.router, prefix="/todos", tags=["todos"]) \ No newline at end of file diff --git a/backend/app/api/v1/endpoints/todos.py b/backend/app/api/v1/endpoints/todos.py new file mode 100644 index 0000000..01423ff --- /dev/null +++ b/backend/app/api/v1/endpoints/todos.py @@ -0,0 +1,75 @@ +from typing import List +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app.api import deps +from app.schemas.todo import Todo, TodoCreate, TodoUpdate +from app.models.todo import Todo as TodoModel +from app.crud.todo import todo + +router = APIRouter() + +@router.get("/", response_model=List[Todo]) +def read_todos( + db: Session = Depends(deps.get_db), + skip: int = 0, + limit: int = 100, +): + """ + Retrieve todos. + """ + return todo.get_multi(db, skip=skip, limit=limit) + +@router.post("/", response_model=Todo) +def create_todo( + *, + db: Session = Depends(deps.get_db), + todo_in: TodoCreate, +): + """ + Create new todo. + """ + return todo.create(db=db, obj_in=todo_in) + +@router.put("/{id}", response_model=Todo) +def update_todo( + *, + db: Session = Depends(deps.get_db), + id: int, + todo_in: TodoUpdate, +): + """ + Update a todo. + """ + todo_current = todo.get(db=db, id=id) + if not todo_current: + raise HTTPException(status_code=404, detail="Todo not found") + return todo.update(db=db, db_obj=todo_current, obj_in=todo_in) + +@router.get("/{id}", response_model=Todo) +def read_todo( + *, + db: Session = Depends(deps.get_db), + id: int, +): + """ + Get todo by ID. + """ + todo_current = todo.get(db=db, id=id) + if not todo_current: + raise HTTPException(status_code=404, detail="Todo not found") + return todo_current + +@router.delete("/{id}", response_model=Todo) +def delete_todo( + *, + db: Session = Depends(deps.get_db), + id: int, +): + """ + Delete todo. + """ + todo_current = todo.get(db=db, id=id) + if not todo_current: + raise HTTPException(status_code=404, detail="Todo not found") + return todo.remove(db=db, id=id) \ No newline at end of file diff --git a/backend/app/core/backend_pre_start.py b/backend/app/backend_pre_start.py similarity index 100% rename from backend/app/core/backend_pre_start.py rename to backend/app/backend_pre_start.py diff --git a/backend/app/repositories/user.py b/backend/app/crud.py similarity index 58% rename from backend/app/repositories/user.py rename to backend/app/crud.py index ad9cd52..905bf48 100644 --- a/backend/app/repositories/user.py +++ b/backend/app/crud.py @@ -1,9 +1,10 @@ +import uuid from typing import Any from sqlmodel import Session, select -from app.core.security import get_password_hash -from app.models import User, UserCreate, UserUpdate +from app.core.security import get_password_hash, verify_password +from app.models import Item, ItemCreate, User, UserCreate, UserUpdate def create_user(*, session: Session, user_create: UserCreate) -> User: @@ -33,4 +34,21 @@ def update_user(*, session: Session, db_user: User, user_in: UserUpdate) -> Any: def get_user_by_email(*, session: Session, email: str) -> User | None: statement = select(User).where(User.email == email) session_user = session.exec(statement).first() - return session_user \ No newline at end of file + return session_user + + +def authenticate(*, session: Session, email: str, password: str) -> User | None: + db_user = get_user_by_email(session=session, email=email) + if not db_user: + return None + if not verify_password(password, db_user.hashed_password): + return None + return db_user + + +def create_item(*, session: Session, item_in: ItemCreate, owner_id: uuid.UUID) -> Item: + db_item = Item.model_validate(item_in, update={"owner_id": owner_id}) + session.add(db_item) + session.commit() + session.refresh(db_item) + return db_item diff --git a/backend/app/crud/todo.py b/backend/app/crud/todo.py new file mode 100644 index 0000000..76ef7d1 --- /dev/null +++ b/backend/app/crud/todo.py @@ -0,0 +1,23 @@ +from typing import List +from fastapi.encoders import jsonable_encoder +from sqlalchemy.orm import Session + +from app.crud.base import CRUDBase +from app.models.todo import Todo +from app.schemas.todo import TodoCreate, TodoUpdate + +class CRUDTodo(CRUDBase[Todo, TodoCreate, TodoUpdate]): + def create(self, db: Session, *, obj_in: TodoCreate) -> Todo: + obj_in_data = jsonable_encoder(obj_in) + db_obj = self.model(**obj_in_data) + db.add(db_obj) + db.commit() + db.refresh(db_obj) + return db_obj + + def get_multi( + self, db: Session, *, skip: int = 0, limit: int = 100 + ) -> List[Todo]: + return db.query(self.model).offset(skip).limit(limit).all() + +todo = CRUDTodo(Todo) \ No newline at end of file diff --git a/backend/app/email-templates/build/new_account.html b/backend/app/email-templates/build/new_account.html new file mode 100644 index 0000000..3445050 --- /dev/null +++ b/backend/app/email-templates/build/new_account.html @@ -0,0 +1,25 @@ +
{{ project_name }} - New Account
Welcome to your new account!
Here are your account details:
Username: {{ username }}
Password: {{ password }}
Go to Dashboard

\ No newline at end of file diff --git a/backend/app/email-templates/build/reset_password.html b/backend/app/email-templates/build/reset_password.html new file mode 100644 index 0000000..4148a5b --- /dev/null +++ b/backend/app/email-templates/build/reset_password.html @@ -0,0 +1,25 @@ +
{{ project_name }} - Password Recovery
Hello {{ username }}
We've received a request to reset your password. You can do it by clicking the button below:
Reset password
Or copy and paste the following link into your browser:
This password will expire in {{ valid_hours }} hours.

If you didn't request a password recovery you can disregard this email.
\ No newline at end of file diff --git a/backend/app/email-templates/build/test_email.html b/backend/app/email-templates/build/test_email.html new file mode 100644 index 0000000..04d0d85 --- /dev/null +++ b/backend/app/email-templates/build/test_email.html @@ -0,0 +1,25 @@ +
{{ project_name }}
Test email for: {{ email }}

\ No newline at end of file diff --git a/backend/app/email-templates/src/new_account.mjml b/backend/app/email-templates/src/new_account.mjml new file mode 100644 index 0000000..f41a3e3 --- /dev/null +++ b/backend/app/email-templates/src/new_account.mjml @@ -0,0 +1,15 @@ + + + + + {{ project_name }} - New Account + Welcome to your new account! + Here are your account details: + Username: {{ username }} + Password: {{ password }} + Go to Dashboard + + + + + diff --git a/backend/app/email-templates/src/reset_password.mjml b/backend/app/email-templates/src/reset_password.mjml new file mode 100644 index 0000000..743f5d7 --- /dev/null +++ b/backend/app/email-templates/src/reset_password.mjml @@ -0,0 +1,17 @@ + + + + + {{ project_name }} - Password Recovery + Hello {{ username }} + We've received a request to reset your password. You can do it by clicking the button below: + Reset password + Or copy and paste the following link into your browser: + {{ link }} + This password will expire in {{ valid_hours }} hours. + + If you didn't request a password recovery you can disregard this email. + + + + diff --git a/backend/app/email-templates/src/test_email.mjml b/backend/app/email-templates/src/test_email.mjml new file mode 100644 index 0000000..45d58d6 --- /dev/null +++ b/backend/app/email-templates/src/test_email.mjml @@ -0,0 +1,11 @@ + + + + + {{ project_name }} + Test email for: {{ email }} + + + + + diff --git a/backend/app/core/initial_data.py b/backend/app/initial_data.py similarity index 100% rename from backend/app/core/initial_data.py rename to backend/app/initial_data.py diff --git a/backend/app/models/user.py b/backend/app/models.py similarity index 52% rename from backend/app/models/user.py rename to backend/app/models.py index 33b1412..2389b4a 100644 --- a/backend/app/models/user.py +++ b/backend/app/models.py @@ -1,9 +1,8 @@ import uuid + from pydantic import EmailStr from sqlmodel import Field, Relationship, SQLModel -from app.models.item import Item - # Shared properties class UserBase(SQLModel): @@ -44,7 +43,7 @@ class UpdatePassword(SQLModel): class User(UserBase, table=True): id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) hashed_password: str - items: list[Item] = Relationship(back_populates="owner", cascade_delete=True) + items: list["Item"] = Relationship(back_populates="owner", cascade_delete=True) # Properties to return via API, id is always required @@ -54,4 +53,61 @@ class UserPublic(UserBase): class UsersPublic(SQLModel): data: list[UserPublic] - count: int \ No newline at end of file + count: int + + +# Shared properties +class ItemBase(SQLModel): + title: str = Field(min_length=1, max_length=255) + description: str | None = Field(default=None, max_length=255) + + +# Properties to receive on item creation +class ItemCreate(ItemBase): + pass + + +# Properties to receive on item update +class ItemUpdate(ItemBase): + title: str | None = Field(default=None, min_length=1, max_length=255) # type: ignore + + +# Database model, database table inferred from class name +class Item(ItemBase, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + owner_id: uuid.UUID = Field( + foreign_key="user.id", nullable=False, ondelete="CASCADE" + ) + owner: User | None = Relationship(back_populates="items") + + +# Properties to return via API, id is always required +class ItemPublic(ItemBase): + id: uuid.UUID + owner_id: uuid.UUID + + +class ItemsPublic(SQLModel): + data: list[ItemPublic] + count: int + + +# Generic message +class Message(SQLModel): + message: str + + +# JSON payload containing access token +class Token(SQLModel): + access_token: str + token_type: str = "bearer" + + +# Contents of JWT token +class TokenPayload(SQLModel): + sub: str | None = None + + +class NewPassword(SQLModel): + token: str + new_password: str = Field(min_length=8, max_length=40) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py deleted file mode 100644 index 0519ecb..0000000 --- a/backend/app/models/__init__.py +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/backend/app/models/auth.py b/backend/app/models/auth.py deleted file mode 100644 index 0f12523..0000000 --- a/backend/app/models/auth.py +++ /dev/null @@ -1,17 +0,0 @@ -from sqlmodel import SQLModel, Field - - -# JSON payload containing access token -class Token(SQLModel): - access_token: str - token_type: str = "bearer" - - -# Contents of JWT token -class TokenPayload(SQLModel): - sub: str | None = None - - -class NewPassword(SQLModel): - token: str - new_password: str = Field(min_length=8, max_length=40) \ No newline at end of file diff --git a/backend/app/models/item.py b/backend/app/models/item.py deleted file mode 100644 index 68a55d0..0000000 --- a/backend/app/models/item.py +++ /dev/null @@ -1,40 +0,0 @@ -import uuid -from sqlmodel import Field, Relationship, SQLModel - -from app.models.user import User - - -# Shared properties -class ItemBase(SQLModel): - title: str = Field(min_length=1, max_length=255) - description: str | None = Field(default=None, max_length=255) - - -# Properties to receive on item creation -class ItemCreate(ItemBase): - pass - - -# Properties to receive on item update -class ItemUpdate(ItemBase): - title: str | None = Field(default=None, min_length=1, max_length=255) # type: ignore - - -# Database model, database table inferred from class name -class Item(ItemBase, table=True): - id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) - owner_id: uuid.UUID = Field( - foreign_key="user.id", nullable=False, ondelete="CASCADE" - ) - owner: User | None = Relationship(back_populates="items") - - -# Properties to return via API, id is always required -class ItemPublic(ItemBase): - id: uuid.UUID - owner_id: uuid.UUID - - -class ItemsPublic(SQLModel): - data: list[ItemPublic] - count: int \ No newline at end of file diff --git a/backend/app/models/todo.py b/backend/app/models/todo.py new file mode 100644 index 0000000..6e53672 --- /dev/null +++ b/backend/app/models/todo.py @@ -0,0 +1,13 @@ +from sqlalchemy import Column, Integer, String, Boolean, DateTime +from sqlalchemy.sql import func +from app.db.base_class import Base + +class Todo(Base): + __tablename__ = "todos" + + id = Column(Integer, primary_key=True, index=True) + title = Column(String, index=True) + description = Column(String, nullable=True) + completed = Column(Boolean, default=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) \ No newline at end of file diff --git a/backend/app/repositories/item.py b/backend/app/repositories/item.py deleted file mode 100644 index a28e359..0000000 --- a/backend/app/repositories/item.py +++ /dev/null @@ -1,12 +0,0 @@ -import uuid -from sqlmodel import Session - -from app.models import Item, ItemCreate - - -def create_item(*, session: Session, item_in: ItemCreate, owner_id: uuid.UUID) -> Item: - db_item = Item.model_validate(item_in, update={"owner_id": owner_id}) - session.add(db_item) - session.commit() - session.refresh(db_item) - return db_item \ No newline at end of file diff --git a/backend/app/schemas/todo.py b/backend/app/schemas/todo.py new file mode 100644 index 0000000..2332745 --- /dev/null +++ b/backend/app/schemas/todo.py @@ -0,0 +1,22 @@ +from pydantic import BaseModel +from datetime import datetime +from typing import Optional + +class TodoBase(BaseModel): + title: str + description: Optional[str] = None + completed: bool = False + +class TodoCreate(TodoBase): + pass + +class TodoUpdate(TodoBase): + pass + +class Todo(TodoBase): + id: int + created_at: datetime + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True \ No newline at end of file diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py deleted file mode 100644 index 5c3c52a..0000000 --- a/backend/app/services/auth.py +++ /dev/null @@ -1,13 +0,0 @@ -from sqlmodel import Session - -from app.core.security import verify_password -from app.repositories.user import get_user_by_email - - -def authenticate(*, session: Session, email: str, password: str) -> User | None: - db_user = get_user_by_email(session=session, email=email) - if not db_user: - return None - if not verify_password(password, db_user.hashed_password): - return None - return db_user \ No newline at end of file diff --git a/backend/app/tests/api/routes/test_login.py b/backend/app/tests/api/routes/test_login.py index a814205..80fa787 100644 --- a/backend/app/tests/api/routes/test_login.py +++ b/backend/app/tests/api/routes/test_login.py @@ -9,7 +9,7 @@ from app.crud import create_user from app.models import UserCreate from app.tests.utils.user import user_authentication_headers from app.tests.utils.utils import random_email, random_lower_string -from app.utils.common import generate_password_reset_token +from app.utils import generate_password_reset_token def test_get_access_token(client: TestClient) -> None: diff --git a/backend/app/core/tests_pre_start.py b/backend/app/tests_pre_start.py similarity index 100% rename from backend/app/core/tests_pre_start.py rename to backend/app/tests_pre_start.py diff --git a/backend/app/utils/common.py b/backend/app/utils.py similarity index 100% rename from backend/app/utils/common.py rename to backend/app/utils.py diff --git a/backend/requirements.txt b/backend/requirements.txt deleted file mode 100644 index 7bae48a..0000000 --- a/backend/requirements.txt +++ /dev/null @@ -1,6 +0,0 @@ -pydantic>=2.8.2 -sqlmodel>=0.0.14 -python-jose[cryptography]>=3.3.0 -passlib[bcrypt]>=1.7.4 -python-multipart>=0.0.6 -email-validator>=2.1.0.post1 \ No newline at end of file diff --git a/scripts/generate-client.sh b/scripts/generate-client.sh index f688c18..1e76864 100644 --- a/scripts/generate-client.sh +++ b/scripts/generate-client.sh @@ -9,4 +9,4 @@ cd .. mv openapi.json frontend/ cd frontend npm run generate-client -npx biome format --write ./src/client \ No newline at end of file +npx biome format --write ./src/client