├── backend
├── src
│ ├── __init__.py
│ ├── events
│ │ ├── tasks.py
│ │ ├── __init__.py
│ │ ├── admin.py
│ │ ├── tests.py
│ │ ├── views.py
│ │ ├── apps.py
│ │ └── models.py
│ ├── orders
│ │ ├── tasks.py
│ │ ├── __init__.py
│ │ ├── validations
│ │ │ ├── __init__.py
│ │ │ └── shipping_validation.py
│ │ ├── admin.py
│ │ ├── tests.py
│ │ ├── views.py
│ │ ├── apps.py
│ │ ├── models
│ │ │ ├── __init__.py
│ │ │ ├── shipping_model.py
│ │ │ ├── item_model.py
│ │ │ └── order_model.py
│ │ └── enums.py
│ ├── reviews
│ │ ├── tasks.py
│ │ ├── __init__.py
│ │ ├── admin.py
│ │ ├── tests.py
│ │ ├── views.py
│ │ ├── apps.py
│ │ └── models.py
│ ├── categories
│ │ ├── tasks.py
│ │ ├── __init__.py
│ │ ├── tests.py
│ │ ├── admin.py
│ │ ├── views.py
│ │ ├── apps.py
│ │ └── models.py
│ ├── common
│ │ ├── __init__.py
│ │ ├── migrations
│ │ │ └── __init__.py
│ │ ├── tests.py
│ │ ├── admin.py
│ │ ├── errors
│ │ │ ├── constants.py
│ │ │ ├── __init__.py
│ │ │ └── exceptions.py
│ │ ├── types.py
│ │ ├── controllers
│ │ │ ├── __init__.py
│ │ │ └── common_controller.py
│ │ ├── apps.py
│ │ ├── renderers.py
│ │ ├── schemas
│ │ │ ├── __init__.py
│ │ │ └── basic_schema.py
│ │ ├── models.py
│ │ ├── permissions.py
│ │ ├── responses.py
│ │ ├── tasks.py
│ │ ├── utils.py
│ │ └── mixins.py
│ ├── core
│ │ ├── settings
│ │ │ ├── prod.py
│ │ │ ├── __init__.py
│ │ │ └── base.py
│ │ ├── __init__.py
│ │ ├── interceptors
│ │ │ ├── __init__.py
│ │ │ └── auth_interceptors.py
│ │ ├── asgi.py
│ │ ├── wsgi.py
│ │ ├── celery.py
│ │ ├── adds.py
│ │ └── config.py
│ ├── products
│ │ ├── __init__.py
│ │ ├── tasks.py
│ │ ├── tests.py
│ │ ├── admin.py
│ │ ├── views.py
│ │ ├── apps.py
│ │ └── models.py
│ ├── users
│ │ ├── __init__.py
│ │ ├── management
│ │ │ ├── __init__.py
│ │ │ └── commands
│ │ │ │ ├── __init__.py
│ │ │ │ ├── _private.py
│ │ │ │ └── make_superuser.py
│ │ ├── migrations
│ │ │ └── __init__.py
│ │ ├── old_controllers
│ │ │ ├── __init__.py
│ │ │ ├── profile_controller.py
│ │ │ └── user_controller.py
│ │ ├── tests.py
│ │ ├── admin.py
│ │ ├── apps.py
│ │ ├── models
│ │ │ ├── __init__.py
│ │ │ ├── profile_model.py
│ │ │ └── user_model.py
│ │ ├── services
│ │ │ └── __init__.py
│ │ ├── interfaces
│ │ │ ├── __init__.py
│ │ │ ├── user_interface.py
│ │ │ └── profile_interface.py
│ │ ├── repositories
│ │ │ └── __init__.py
│ │ ├── types.py
│ │ ├── files.py
│ │ ├── utils.py
│ │ ├── errors
│ │ │ ├── profile_error.py
│ │ │ ├── __init__.py
│ │ │ └── user_error.py
│ │ ├── validations
│ │ │ ├── __init__.py
│ │ │ ├── user_validation.py
│ │ │ └── profile_validation.py
│ │ ├── enums.py
│ │ ├── schemas
│ │ │ ├── schema.py
│ │ │ ├── __init__.py
│ │ │ └── profile_schema.py
│ │ ├── tasks.py
│ │ └── controllers.py
│ ├── auth
│ │ ├── controllers
│ │ │ ├── __init__.py
│ │ │ └── auth_controller.py
│ │ ├── services
│ │ │ └── __init__.py
│ │ ├── schemas
│ │ │ └── __init__.py
│ │ ├── tasks.py
│ │ ├── errors.py
│ │ ├── throttles.py
│ │ └── utils.py
│ ├── data
│ │ ├── managers
│ │ │ ├── __init__.py
│ │ │ ├── task_manager.py
│ │ │ └── mail_manager.py
│ │ ├── interfaces
│ │ │ ├── client
│ │ │ │ └── abstract_client.py
│ │ │ ├── handler
│ │ │ │ ├── abstract_mail.py
│ │ │ │ ├── abstract_phone.py
│ │ │ │ ├── abstract_event.py
│ │ │ │ ├── abstract_cache.py
│ │ │ │ └── abstract_file.py
│ │ │ ├── storage
│ │ │ │ ├── abstract_cache.py
│ │ │ │ └── abstract_cloud.py
│ │ │ ├── __init__.py
│ │ │ └── managers
│ │ │ │ └── abstract_event_manager.py
│ │ ├── storages
│ │ │ ├── __init__.py
│ │ │ └── redis_storage.py
│ │ ├── clients
│ │ │ ├── __init__.py
│ │ │ ├── vonage_client.py
│ │ │ ├── redis_client.py
│ │ │ ├── mail_client.py
│ │ │ ├── minio_client.py
│ │ │ ├── amazon_client.py
│ │ │ └── celery_client.py
│ │ ├── handlers
│ │ │ ├── __init__.py
│ │ │ ├── template_handler.py
│ │ │ ├── mail_handler.py
│ │ │ ├── redis_handler.py
│ │ │ └── event_handler.py
│ │ └── utils.py
│ ├── files
│ │ ├── schemas.py
│ │ ├── errors.py
│ │ ├── controllers.py
│ │ └── services.py
│ └── api.py
├── media
│ ├── register.png
│ └── files
│ │ └── country_codes.csv
├── .gitignore
├── docker
│ ├── celery
│ │ ├── worker.sh
│ │ ├── beat.sh
│ │ └── flower.sh
│ ├── redis
│ │ ├── Dockerfile
│ │ └── init.sh
│ ├── django.sh
│ ├── scripts
│ │ └── wait_for_port.sh
│ ├── entrypoint.sh
│ └── ninja
│ │ └── Dockerfile
├── main.py
├── templates
│ └── register-mail.html
├── README.md
├── manage.py
└── pyproject.toml
├── frontend
├── src
│ ├── App.css
│ ├── pages
│ │ ├── auth
│ │ │ ├── LoginPage
│ │ │ │ ├── index.tsx
│ │ │ │ └── LoginPage.tsx
│ │ │ └── RegisterPage
│ │ │ │ ├── index.tsx
│ │ │ │ └── RegisterPage.tsx
│ │ └── HomePage
│ │ │ └── HomePage.tsx
│ ├── contexts
│ │ ├── ThemeContextInterface.tsx
│ │ └── ThemeContext.tsx
│ ├── components
│ │ └── nav
│ │ │ ├── index.tsx
│ │ │ ├── Header.css
│ │ │ ├── ButtonLightDark.tsx
│ │ │ └── Header.tsx
│ ├── App.tsx
│ ├── dev
│ │ ├── index.ts
│ │ ├── palette.tsx
│ │ ├── useInitial.ts
│ │ ├── previews.tsx
│ │ └── README.md
│ ├── vite-env.d.ts
│ ├── hooks
│ │ └── useTheme.tsx
│ ├── index.css
│ ├── main.tsx
│ ├── routes.tsx
│ ├── providers
│ │ └── ThemeProvider.tsx
│ └── assets
│ │ └── react.svg
├── .eslintignore
├── .prettierignore
├── .prettierrc.json
├── docker
│ └── Dockerfile
├── tsconfig.node.json
├── index.html
├── .gitignore
├── vite.config.ts
├── tsconfig.json
├── .eslintrc.json
├── package.json
├── README.md
└── public
│ └── vite.svg
├── .python-version
├── .envs
├── dev
│ ├── flower.env
│ ├── mail.env
│ ├── postgres.env
│ ├── redis.env
│ ├── minio.env
│ ├── react.env
│ └── django.env
└── .template.env
├── commands
└── dev
│ ├── run.sh
│ ├── run_less_backend.sh
│ ├── load-env.sh
│ ├── install.sh
│ ├── debug.sh
│ └── backend
│ ├── delete_volume.sh
│ ├── delete_migrations_files.sh
│ └── makemigrations.sh
├── .dockerignore
├── README.dev.md
├── .pre-commit-config.yaml
├── README.md
├── LICENSE
└── .gitignore
/backend/src/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/frontend/src/App.css:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.python-version:
--------------------------------------------------------------------------------
1 | 3.11.7
2 |
--------------------------------------------------------------------------------
/backend/src/events/tasks.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/src/orders/tasks.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/src/reviews/tasks.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/src/categories/tasks.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/src/common/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/src/core/settings/prod.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/src/events/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/src/orders/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/src/products/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/src/products/tasks.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/src/reviews/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/src/users/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/src/categories/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/src/core/settings/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/src/common/migrations/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/src/orders/validations/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/src/users/management/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/src/users/migrations/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/src/users/old_controllers/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/src/users/management/commands/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/src/users/management/commands/_private.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/frontend/.eslintignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | src/dev
3 |
--------------------------------------------------------------------------------
/backend/src/common/tests.py:
--------------------------------------------------------------------------------
1 | # Create your tests here.
2 |
--------------------------------------------------------------------------------
/backend/src/users/tests.py:
--------------------------------------------------------------------------------
1 | # Create your tests here.
2 |
--------------------------------------------------------------------------------
/backend/src/users/admin.py:
--------------------------------------------------------------------------------
1 | # RegisterPage your models here.
2 |
--------------------------------------------------------------------------------
/backend/src/common/admin.py:
--------------------------------------------------------------------------------
1 | # RegisterPage your models here.
2 |
--------------------------------------------------------------------------------
/frontend/.prettierignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | package.lock.json
3 | build
4 |
--------------------------------------------------------------------------------
/.envs/dev/flower.env:
--------------------------------------------------------------------------------
1 | # flower
2 | CELERY_FLOWER_USER=admin
3 | CELERY_FLOWER_PASSWORD=admin
4 |
--------------------------------------------------------------------------------
/backend/src/categories/tests.py:
--------------------------------------------------------------------------------
1 | from django.test import TestCase
2 |
3 | # Create your tests here.
4 |
--------------------------------------------------------------------------------
/backend/src/events/admin.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 |
3 | # Register your models here.
4 |
--------------------------------------------------------------------------------
/backend/src/events/tests.py:
--------------------------------------------------------------------------------
1 | from django.test import TestCase
2 |
3 | # Create your tests here.
4 |
--------------------------------------------------------------------------------
/backend/src/events/views.py:
--------------------------------------------------------------------------------
1 | from django.shortcuts import render
2 |
3 | # Create your views here.
4 |
--------------------------------------------------------------------------------
/backend/src/orders/admin.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 |
3 | # Register your models here.
4 |
--------------------------------------------------------------------------------
/backend/src/orders/tests.py:
--------------------------------------------------------------------------------
1 | from django.test import TestCase
2 |
3 | # Create your tests here.
4 |
--------------------------------------------------------------------------------
/backend/src/orders/views.py:
--------------------------------------------------------------------------------
1 | from django.shortcuts import render
2 |
3 | # Create your views here.
4 |
--------------------------------------------------------------------------------
/backend/src/products/tests.py:
--------------------------------------------------------------------------------
1 | from django.test import TestCase
2 |
3 | # Create your tests here.
4 |
--------------------------------------------------------------------------------
/backend/src/reviews/admin.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 |
3 | # Register your models here.
4 |
--------------------------------------------------------------------------------
/backend/src/reviews/tests.py:
--------------------------------------------------------------------------------
1 | from django.test import TestCase
2 |
3 | # Create your tests here.
4 |
--------------------------------------------------------------------------------
/backend/src/reviews/views.py:
--------------------------------------------------------------------------------
1 | from django.shortcuts import render
2 |
3 | # Create your views here.
4 |
--------------------------------------------------------------------------------
/frontend/src/pages/auth/LoginPage/index.tsx:
--------------------------------------------------------------------------------
1 | export { default as LoginPage } from "./LoginPage";
2 |
--------------------------------------------------------------------------------
/backend/src/categories/admin.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 |
3 | # Register your models here.
4 |
--------------------------------------------------------------------------------
/backend/src/categories/views.py:
--------------------------------------------------------------------------------
1 | from django.shortcuts import render
2 |
3 | # Create your views here.
4 |
--------------------------------------------------------------------------------
/backend/src/products/admin.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 |
3 | # Register your models here.
4 |
--------------------------------------------------------------------------------
/backend/src/products/views.py:
--------------------------------------------------------------------------------
1 | from django.shortcuts import render
2 |
3 | # Create your views here.
4 |
--------------------------------------------------------------------------------
/frontend/src/pages/auth/RegisterPage/index.tsx:
--------------------------------------------------------------------------------
1 | export { default as RegisterPage } from "./RegisterPage";
2 |
--------------------------------------------------------------------------------
/backend/media/register.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/radthenone/olivin_store/HEAD/backend/media/register.png
--------------------------------------------------------------------------------
/backend/src/core/__init__.py:
--------------------------------------------------------------------------------
1 | from src.core.celery import celery as celery_app
2 |
3 | __all__ = ("celery_app",)
4 |
--------------------------------------------------------------------------------
/backend/.gitignore:
--------------------------------------------------------------------------------
1 | ../.python-version
2 | poetry.lock
3 | staticfiles
4 | logs
5 | src/**/migrations/*
6 | VERSION.txt
7 |
--------------------------------------------------------------------------------
/backend/src/common/errors/constants.py:
--------------------------------------------------------------------------------
1 | class StatusCodes:
2 | SERVER_ERROR = 500
3 | NOT_FOUND = 404
4 | CONFLICT = 409
5 |
--------------------------------------------------------------------------------
/.envs/dev/mail.env:
--------------------------------------------------------------------------------
1 | # email
2 | EMAIL_HOST=127.0.0.1
3 | EMAIL_HOST_USER=
4 | EMAIL_HOST_PASSWORD=
5 | EMAIL_PORT=1025
6 | EMAIL_USE_TLS=False
7 |
--------------------------------------------------------------------------------
/backend/src/auth/controllers/__init__.py:
--------------------------------------------------------------------------------
1 | from src.auth.controllers.auth_controller import AuthController
2 |
3 | __all__ = ["AuthController"]
4 |
--------------------------------------------------------------------------------
/backend/src/auth/services/__init__.py:
--------------------------------------------------------------------------------
1 | from src.auth.services.auth_service import AuthService
2 |
3 | __all__ = [
4 | "AuthService",
5 | ]
6 |
--------------------------------------------------------------------------------
/frontend/src/contexts/ThemeContextInterface.tsx:
--------------------------------------------------------------------------------
1 | export interface ThemeContextProps {
2 | theme: string;
3 | toggleTheme: () => void;
4 | }
5 |
--------------------------------------------------------------------------------
/.envs/dev/postgres.env:
--------------------------------------------------------------------------------
1 | POSTGRES_HOST=db
2 | POSTGRES_PORT=5432
3 | POSTGRES_DB=postgres-olivin
4 | POSTGRES_USER=olivin
5 | POSTGRES_PASSWORD=olivin
6 |
--------------------------------------------------------------------------------
/frontend/src/components/nav/index.tsx:
--------------------------------------------------------------------------------
1 | export { default as Header } from "./Header";
2 | export { default as ButtonLightDark } from "./ButtonLightDark";
3 |
--------------------------------------------------------------------------------
/backend/src/common/types.py:
--------------------------------------------------------------------------------
1 | from typing import TypeVar
2 |
3 | from pydantic import BaseModel
4 |
5 | SchemaType = TypeVar("SchemaType", bound=BaseModel)
6 |
--------------------------------------------------------------------------------
/frontend/src/components/nav/Header.css:
--------------------------------------------------------------------------------
1 | .header {
2 | background-color: red;
3 | display: flex;
4 | justify-content: center;
5 | align-items: center;
6 | }
7 |
--------------------------------------------------------------------------------
/.envs/dev/redis.env:
--------------------------------------------------------------------------------
1 | REDIS_HOST=redis
2 | REDIS_PORT=6379
3 | REDIS_DB=0
4 | REDIS_PASSWORD=some_redis_password
5 | REDIS_URL=redis://:some_redis_password@redis:6379/0
6 |
--------------------------------------------------------------------------------
/backend/docker/celery/worker.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -o errexit
4 | set -o nounset
5 |
6 | sleep 5
7 |
8 | celery -A src.core.celery worker -l INFO -Q tasks,events
9 |
--------------------------------------------------------------------------------
/backend/src/common/controllers/__init__.py:
--------------------------------------------------------------------------------
1 | from src.common.controllers.common_controller import CommonController
2 |
3 | __all__ = [
4 | "CommonController",
5 | ]
6 |
--------------------------------------------------------------------------------
/.envs/.template.env:
--------------------------------------------------------------------------------
1 | AWS_ACCESS_KEY=
2 | AWS_SECRET_KEY=
3 | AWS_REGION_NAME=
4 | API_BACKEND_URL=
5 | API_FRONTEND_URL=
6 | API_VERSION=
7 | VONAGE_API_KEY=
8 | VONAGE_API_SECRET=
9 |
--------------------------------------------------------------------------------
/.envs/dev/minio.env:
--------------------------------------------------------------------------------
1 | MINIO_ROOT_USER=minioadmin
2 | MINIO_ROOT_PASSWORD=minioadmin
3 | MINIO_HOST=minio
4 | MINIO_PORT=9000
5 | BUCKET_NAME=olivin-d2e3e393-9767-413b-b6d5-cf8fd6166de0
6 |
--------------------------------------------------------------------------------
/backend/src/common/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 |
3 |
4 | class CommonConfig(AppConfig):
5 | default_auto_field = "django.db.models.BigAutoField"
6 | name = "src.common"
7 |
--------------------------------------------------------------------------------
/backend/src/events/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 |
3 |
4 | class EventsConfig(AppConfig):
5 | default_auto_field = "django.db.models.BigAutoField"
6 | name = "src.events"
7 |
--------------------------------------------------------------------------------
/backend/src/orders/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 |
3 |
4 | class OrdersConfig(AppConfig):
5 | default_auto_field = "django.db.models.BigAutoField"
6 | name = "src.orders"
7 |
--------------------------------------------------------------------------------
/backend/src/users/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 |
3 |
4 | class UsersConfig(AppConfig):
5 | default_auto_field = "django.db.models.BigAutoField"
6 | name = "src.users"
7 |
--------------------------------------------------------------------------------
/.envs/dev/react.env:
--------------------------------------------------------------------------------
1 | NODE_ENV=development
2 | WDS_SOCKET_HOST=127.0.0.1
3 | CHOKIDAR_USEPOLLING=true
4 | WATCHPACK_POLLING=true
5 | HOST=0.0.0.0
6 | PORT=3000
7 | API_BACKEND_URL=http://127.0.0.1:8000
8 |
--------------------------------------------------------------------------------
/backend/src/reviews/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 |
3 |
4 | class ReviewsConfig(AppConfig):
5 | default_auto_field = "django.db.models.BigAutoField"
6 | name = "src.reviews"
7 |
--------------------------------------------------------------------------------
/backend/src/users/models/__init__.py:
--------------------------------------------------------------------------------
1 | from src.users.models.profile_model import Profile
2 | from src.users.models.user_model import User
3 |
4 | __all__ = [
5 | "User",
6 | "Profile",
7 | ]
8 |
--------------------------------------------------------------------------------
/backend/src/products/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 |
3 |
4 | class ProductsConfig(AppConfig):
5 | default_auto_field = "django.db.models.BigAutoField"
6 | name = "src.products"
7 |
--------------------------------------------------------------------------------
/backend/src/categories/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 |
3 |
4 | class CategoriesConfig(AppConfig):
5 | default_auto_field = "django.db.models.BigAutoField"
6 | name = "src.categories"
7 |
--------------------------------------------------------------------------------
/frontend/src/pages/auth/LoginPage/LoginPage.tsx:
--------------------------------------------------------------------------------
1 | const LoginPage = () => {
2 | return (
3 |
4 |
Login Page
5 |
6 | );
7 | };
8 |
9 | export default LoginPage;
10 |
--------------------------------------------------------------------------------
/backend/docker/celery/beat.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -o errexit
4 | set -o nounset
5 |
6 |
7 | rm -f './celerybeat.pid'
8 |
9 | sleep 5
10 |
11 | celery -A src.core.celery worker -l INFO -Q beats -E -B
12 |
--------------------------------------------------------------------------------
/frontend/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 100,
3 | "semi": true,
4 | "singleQuote": true,
5 | "tabWidth": 2,
6 | "useTabs": false,
7 | "trailingComma": "all",
8 | "endOfLine": "auto"
9 | }
10 |
--------------------------------------------------------------------------------
/backend/src/core/interceptors/__init__.py:
--------------------------------------------------------------------------------
1 | from src.core.interceptors.auth_interceptors import (
2 | AuthBearer,
3 | get_user_id,
4 | )
5 |
6 | __all__ = [
7 | "AuthBearer",
8 | "get_user_id",
9 | ]
10 |
--------------------------------------------------------------------------------
/frontend/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { RouterProvider } from "react-router-dom";
2 | import router from "./routes.tsx";
3 |
4 | const App = () => {
5 | return ;
6 | };
7 |
8 | export default App;
9 |
--------------------------------------------------------------------------------
/frontend/src/pages/auth/RegisterPage/RegisterPage.tsx:
--------------------------------------------------------------------------------
1 | const RegisterPage = () => {
2 | return (
3 |
4 |
Register Page
5 |
6 | );
7 | };
8 |
9 | export default RegisterPage;
10 |
--------------------------------------------------------------------------------
/backend/src/data/managers/__init__.py:
--------------------------------------------------------------------------------
1 | from src.data.managers.event_manager import EventManager
2 | from src.data.managers.mail_manager import MailManager
3 |
4 | __all__ = [
5 | "EventManager",
6 | "MailManager",
7 | ]
8 |
--------------------------------------------------------------------------------
/frontend/src/dev/index.ts:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useInitial } from "./useInitial";
3 |
4 | const ComponentPreviews = React.lazy(() => import("./previews"));
5 |
6 | export { ComponentPreviews, useInitial };
7 |
--------------------------------------------------------------------------------
/backend/src/users/services/__init__.py:
--------------------------------------------------------------------------------
1 | from src.users.services.profile_service import ProfileService
2 | from src.users.services.user_service import UserService
3 |
4 | __all__ = [
5 | "UserService",
6 | "ProfileService",
7 | ]
8 |
--------------------------------------------------------------------------------
/frontend/docker/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:alpine
2 |
3 | WORKDIR /app
4 |
5 | COPY package*.json ./
6 |
7 | RUN npm ci
8 | RUN npm cache clean --force
9 |
10 | COPY . .
11 |
12 | EXPOSE 3000
13 |
14 | CMD ["npm", "run", "dev"]
15 |
--------------------------------------------------------------------------------
/backend/docker/redis/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM redis:7.2
2 |
3 | COPY ./docker/redis/init.sh /redis/init.sh
4 | RUN sed -i 's/\r$//g' /redis/init.sh && chmod +x /redis/init.sh
5 |
6 | WORKDIR /redis
7 |
8 | EXPOSE 6379
9 |
10 | CMD ["./init.sh"]
11 |
--------------------------------------------------------------------------------
/frontend/src/pages/HomePage/HomePage.tsx:
--------------------------------------------------------------------------------
1 | const HomePage = () => {
2 | return (
3 |
4 |
Home Page
5 |
{process.env.API_BACKEND_URL}
6 |
7 | );
8 | };
9 |
10 | export default HomePage;
11 |
--------------------------------------------------------------------------------
/frontend/src/contexts/ThemeContext.tsx:
--------------------------------------------------------------------------------
1 | import { createContext } from "react";
2 | import { ThemeContextProps } from "./ThemeContextInterface.tsx";
3 |
4 | export const ThemeContext = createContext(
5 | undefined,
6 | );
7 |
--------------------------------------------------------------------------------
/backend/main.py:
--------------------------------------------------------------------------------
1 | if __name__ == "__main__":
2 | import uvicorn
3 |
4 | uvicorn.run(
5 | "src.core.asgi:application",
6 | host="localhost",
7 | port=8080,
8 | log_level="info",
9 | reload=True,
10 | )
11 |
--------------------------------------------------------------------------------
/backend/src/users/interfaces/__init__.py:
--------------------------------------------------------------------------------
1 | from src.users.interfaces.profile_interface import IProfileRepository
2 | from src.users.interfaces.user_interface import IUserRepository
3 |
4 | __all__ = [
5 | "IUserRepository",
6 | "IProfileRepository",
7 | ]
8 |
--------------------------------------------------------------------------------
/commands/dev/run.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | set -o errexit
3 | set -o pipefail
4 | set -o nounset
5 |
6 | source ./commands/dev/load-env.sh
7 |
8 | if [ "${DEBUG}" = 1 ]; then
9 | echo "Starting development"
10 | docker-compose --profile dev start
11 | fi
12 |
--------------------------------------------------------------------------------
/backend/src/users/repositories/__init__.py:
--------------------------------------------------------------------------------
1 | from src.users.repositories.profile_repository import ProfileRepository
2 | from src.users.repositories.user_repository import UserRepository
3 |
4 | __all__ = [
5 | "UserRepository",
6 | "ProfileRepository",
7 | ]
8 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | # Ignore everything
2 | frontend/node_modules
3 | .envs
4 | commands
5 | .git
6 | .gitignore
7 | .pre-commit-config.yaml
8 | .python-version
9 | LICENSE
10 | *.md
11 | **/site-packages/*
12 |
13 |
14 |
15 | # Allow files and directories
16 | !/src
17 |
--------------------------------------------------------------------------------
/backend/src/common/errors/__init__.py:
--------------------------------------------------------------------------------
1 | from src.common.errors.constants import StatusCodes
2 | from src.common.errors.exceptions import BasicHTTPException, HTTPException
3 |
4 | __all__ = [
5 | "StatusCodes",
6 | "BasicHTTPException",
7 | "HTTPException",
8 | ]
9 |
--------------------------------------------------------------------------------
/frontend/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | interface ImportMetaEnv {
3 | readonly API_BACKEND_URL: string;
4 | readonly HOST: string;
5 | readonly PORT: string;
6 | }
7 |
8 | interface ImportMeta {
9 | readonly env: ImportMetaEnv;
10 | }
11 |
--------------------------------------------------------------------------------
/backend/src/common/renderers.py:
--------------------------------------------------------------------------------
1 | import orjson
2 | from ninja.renderers import BaseRenderer
3 |
4 |
5 | class ORJSONRenderer(BaseRenderer):
6 | media_type = "application/json"
7 |
8 | def render(self, request, data, *, response_status):
9 | return orjson.dumps(data)
10 |
--------------------------------------------------------------------------------
/backend/src/orders/models/__init__.py:
--------------------------------------------------------------------------------
1 | from src.orders.models.item_model import OrderItem
2 | from src.orders.models.order_model import Order
3 | from src.orders.models.shipping_model import Shipping
4 |
5 | __all__ = [
6 | "Order",
7 | "OrderItem",
8 | "Shipping",
9 | ]
10 |
--------------------------------------------------------------------------------
/commands/dev/run_less_backend.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | set -o errexit
3 | set -o pipefail
4 | set -o nounset
5 |
6 | source ./commands/dev/load-env.sh
7 |
8 | if [ "${DEBUG}" = 1 ]; then
9 | echo "Starting development wait for backend"
10 | docker-compose --profile less-dev start
11 | fi
12 |
--------------------------------------------------------------------------------
/backend/src/users/types.py:
--------------------------------------------------------------------------------
1 | from typing import TypeVar
2 |
3 | from django.contrib.auth import get_user_model
4 |
5 | from src.users.models import Profile
6 |
7 | User = get_user_model()
8 |
9 | UserType = TypeVar("UserType", bound=User)
10 | ProfileType = TypeVar("ProfileType", bound=Profile)
11 |
--------------------------------------------------------------------------------
/commands/dev/load-env.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | set -o errexit
3 | set -o pipefail
4 | set -o nounset
5 |
6 | echo "Loading development environment"
7 | source "${PWD}"/.envs/dev/react.env
8 | source "${PWD}"/.envs/dev/django.env
9 | source "${PWD}"/.envs/dev/postgres.env
10 | source "${PWD}"/.envs/dev/redis.env
11 |
--------------------------------------------------------------------------------
/backend/src/data/interfaces/client/abstract_client.py:
--------------------------------------------------------------------------------
1 | from abc import ABC, abstractmethod
2 |
3 |
4 | class IClient(ABC):
5 | @abstractmethod
6 | def connect(self, *args, **kwargs) -> None:
7 | pass
8 |
9 | @abstractmethod
10 | def disconnect(self, *args, **kwargs) -> None:
11 | pass
12 |
--------------------------------------------------------------------------------
/backend/src/data/storages/__init__.py:
--------------------------------------------------------------------------------
1 | from src.data.storages.amazon_storage import AmazonS3Storage
2 | from src.data.storages.minio_storage import MinioStorage
3 | from src.data.storages.redis_storage import RedisStorage
4 |
5 | __all__ = [
6 | "RedisStorage",
7 | "AmazonS3Storage",
8 | "MinioStorage",
9 | ]
10 |
--------------------------------------------------------------------------------
/backend/src/common/schemas/__init__.py:
--------------------------------------------------------------------------------
1 | from src.common.schemas.basic_schema import (
2 | CreatedAtSchema,
3 | MessageSchema,
4 | PasswordsMatchSchema,
5 | UpdatedAtSchema,
6 | )
7 |
8 | __all__ = [
9 | "CreatedAtSchema",
10 | "UpdatedAtSchema",
11 | "MessageSchema",
12 | "PasswordsMatchSchema",
13 | ]
14 |
--------------------------------------------------------------------------------
/commands/dev/install.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | set -o errexit
3 | set -o pipefail
4 | set -o nounset
5 |
6 | source ./commands/dev/load-env.sh
7 |
8 | if [ "${DEBUG}" = 1 ]; then
9 | echo "Starting install development"
10 | docker-compose --profile dev up --build -d
11 | docker-compose --profile dev stop
12 | echo "Development created"
13 | fi
14 |
--------------------------------------------------------------------------------
/commands/dev/debug.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | set -o errexit
3 | set -o pipefail
4 | set -o nounset
5 |
6 | source ./commands/dev/load-env.sh
7 |
8 | if [ "${DEBUG}" = 1 ]; then
9 | echo "Starting debugging"
10 | docker-compose --profile dev stop
11 | docker-compose --profile dev-less start
12 | docker-compose run --rm --service-ports backend
13 | fi
14 |
--------------------------------------------------------------------------------
/frontend/src/hooks/useTheme.tsx:
--------------------------------------------------------------------------------
1 | import { useContext } from "react";
2 | import { ThemeContext } from "context/ThemeContext.tsx";
3 |
4 | export const useTheme = () => {
5 | const context = useContext(ThemeContext);
6 | if (!context) {
7 | throw new Error("useTheme must be used within a ThemeProvider");
8 | }
9 | return context;
10 | };
11 |
--------------------------------------------------------------------------------
/frontend/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "skipLibCheck": true,
5 | "module": "ESNext",
6 | "moduleResolution": "bundler",
7 | "allowSyntheticDefaultImports": true,
8 | "strict": true
9 | },
10 | "include": ["vite.config.ts"],
11 | "exclude": ["node_modules", "build", "dist"]
12 | }
13 |
--------------------------------------------------------------------------------
/backend/src/data/interfaces/handler/abstract_mail.py:
--------------------------------------------------------------------------------
1 | from abc import ABC, abstractmethod
2 | from typing import Any, Optional
3 |
4 |
5 | class IRegistrationEmailHandler(ABC):
6 | @abstractmethod
7 | def send_registration_email(
8 | self,
9 | to_email: str,
10 | context: Optional[dict[str, Any]] = None,
11 | ) -> bool:
12 | pass
13 |
--------------------------------------------------------------------------------
/backend/src/users/files.py:
--------------------------------------------------------------------------------
1 | import csv
2 |
3 |
4 | def read_country_codes(file_path) -> dict[str, str]:
5 | country_codes = {}
6 | with open(file_path, mode="r", encoding="utf-8") as file:
7 | reader = csv.reader(file)
8 | next(reader)
9 | for row in reader:
10 | country_codes[row[0].upper()] = row[2]
11 | return country_codes
12 |
--------------------------------------------------------------------------------
/backend/src/common/models.py:
--------------------------------------------------------------------------------
1 | import json
2 | import uuid
3 | from typing import Any, Optional
4 |
5 | from django.db import models
6 | from django.utils import timezone
7 |
8 |
9 | class CreatedUpdatedDateModel(models.Model):
10 | created_at = models.DateTimeField(db_index=True, default=timezone.now)
11 | updated_at = models.DateTimeField(auto_now=True)
12 |
13 | class Meta:
14 | abstract = True
15 |
--------------------------------------------------------------------------------
/commands/dev/backend/delete_volume.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | set -o errexit
3 | set -o pipefail
4 | set -o nounset
5 |
6 | if [ $# -ne 2 ]; then
7 | echo "Need to specify the container and volume names."
8 | echo "delete_volume.sh "
9 | exit 1
10 | fi
11 |
12 | container_name=$1
13 | volume_name=$2
14 |
15 | docker container rm -f "$container_name"
16 | docker volume rm "$volume_name"
17 |
--------------------------------------------------------------------------------
/frontend/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Vite + React + TS
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/backend/src/users/utils.py:
--------------------------------------------------------------------------------
1 | import random
2 |
3 |
4 | def generate_code() -> str:
5 | code = "".join([str(random.randint(0, 9)) for _ in range(4)])
6 | return code
7 |
8 |
9 | def get_task_result(task_id: str, timeout: int = 10):
10 | from celery.result import AsyncResult
11 |
12 | task = AsyncResult(task_id)
13 | if task.ready():
14 | return task.get(timeout=timeout)
15 | else:
16 | return None
17 |
--------------------------------------------------------------------------------
/backend/src/categories/models.py:
--------------------------------------------------------------------------------
1 | from django.db import models
2 |
3 | # Create your models here.
4 |
5 |
6 | class Category(models.Model):
7 | id = models.AutoField(
8 | primary_key=True,
9 | editable=False,
10 | )
11 | name = models.CharField(
12 | max_length=100,
13 | unique=True,
14 | null=False,
15 | blank=False,
16 | )
17 |
18 | def __str__(self):
19 | return self.name
20 |
--------------------------------------------------------------------------------
/backend/src/files/schemas.py:
--------------------------------------------------------------------------------
1 | from pydantic import (
2 | BaseModel,
3 | ConfigDict,
4 | )
5 |
6 |
7 | class AvatarSchema(BaseModel):
8 | avatar: str
9 |
10 | model_config = ConfigDict(
11 | json_schema_extra={
12 | "description": "Avatar schema",
13 | "title": "AvatarSchema",
14 | "example": {
15 | "avatar": "https://example.com/avatar.png",
16 | },
17 | },
18 | )
19 |
--------------------------------------------------------------------------------
/frontend/src/index.css:
--------------------------------------------------------------------------------
1 | :root {
2 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
3 | line-height: 1.5;
4 | font-weight: 400;
5 |
6 | font-synthesis: none;
7 | text-rendering: optimizeLegibility;
8 | -webkit-font-smoothing: antialiased;
9 | -moz-osx-font-smoothing: grayscale;
10 | }
11 |
12 | html,
13 | body {
14 | padding: 0;
15 | margin: 0;
16 | display: flex;
17 | flex-direction: column;
18 | min-height: 100%;
19 | }
20 |
--------------------------------------------------------------------------------
/frontend/src/components/nav/ButtonLightDark.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "antd";
2 | import { SunOutlined, MoonOutlined } from "@ant-design/icons";
3 | import { useTheme } from "hooks/useTheme";
4 |
5 | function ButtonLightDark() {
6 | const { theme, toggleTheme } = useTheme();
7 |
8 | return (
9 | : }
12 | />
13 | );
14 | }
15 |
16 | export default ButtonLightDark;
17 |
--------------------------------------------------------------------------------
/backend/docker/django.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | # if any of the commands in your code fails for any reason, the entire script fails
3 | set -o errexit
4 | # fail exit if one of your pipe command fails
5 | set -o pipefail
6 | # exits if any of your variables is not set
7 | set -o nounset
8 |
9 | export DJANGO_SETTINGS_MODULE="${DJANGO_SETTINGS_MODULE}"
10 |
11 | echo "Continuing with the execution."
12 |
13 | echo "========== DJANGO RUNSERVER =========="
14 |
15 | python app.py --wsgi
16 |
17 | exec "$@"
18 |
--------------------------------------------------------------------------------
/backend/docker/redis/init.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | # if any of the commands in your code fails for any reason, the entire script fails
3 | set -o errexit
4 | # fail exit if one of your pipe command fails
5 | set -o pipefail
6 | # exits if any of your variables is not set
7 | set -o nounset
8 |
9 | echo "Starting redis server..."
10 | # Start redis server
11 | redis-server --bind 0.0.0.0 --requirepass "${REDIS_PASSWORD}" --maxmemory 256mb --maxmemory-policy allkeys-lru --appendonly yes
12 |
13 | echo "Redis server started"
14 |
--------------------------------------------------------------------------------
/README.dev.md:
--------------------------------------------------------------------------------
1 | ## Develop readme to use:
2 |
3 |
4 | ### install docker project
5 | ```bash
6 | docker-compose --profile dev up --build -d
7 | ```
8 |
9 | ### run docker ninja-django
10 | ```bash
11 | docker-compose --profile dev up -d
12 | ```
13 |
14 | ### dev project start
15 | ```bash
16 | python manage.py runserver "0.0.0.0:8000"
17 | ```
18 |
19 | ### add migrations
20 | ```bash
21 | ./commands/dev/backend/makemigrations.sh
22 | ```
23 |
24 | ### delete all migrations
25 | ```bash
26 | ./commands/dev/backend/delete_migrations.sh
27 | ```
28 |
--------------------------------------------------------------------------------
/backend/src/orders/enums.py:
--------------------------------------------------------------------------------
1 | import enum
2 |
3 |
4 | class OrderStatus(enum.Enum):
5 | CREATED = "CREATED"
6 | ORDERED = "ORDERED"
7 | PREPARED = "PREPARED"
8 | SHIPPED = "SHIPPED"
9 | CANCELED = "CANCELED"
10 | DELIVERED = "DELIVERED"
11 | RETURNED = "RETURNED"
12 | DELETED = "DELETED"
13 |
14 |
15 | class PaymentMethod(enum.Enum):
16 | CREDIT_CARD = "CREDIT_CARD"
17 | PAYPAL = "PAYPAL"
18 | INPOST = "INPOST"
19 | DHL = "DHL"
20 | POCZTEX = "POCZTEX"
21 | UPS = "UPS"
22 | GLS = "GLS"
23 |
--------------------------------------------------------------------------------
/backend/src/core/asgi.py:
--------------------------------------------------------------------------------
1 | """
2 | ASGI config for core_old project.
3 |
4 | It exposes the ASGI callable as a module-level variable named ``application``.
5 |
6 | For more information on this file, see
7 | https://docs.djangoproject.com/en/5.0/howto/deployment/asgi/
8 | """
9 |
10 | import os
11 |
12 | from django.core.asgi import get_asgi_application
13 |
14 | os.environ.setdefault(
15 | "DJANGO_SETTINGS_MODULE",
16 | os.getenv("DJANGO_SETTINGS_MODULE", "src.core.settings.dev"),
17 | )
18 |
19 |
20 | application = get_asgi_application()
21 |
--------------------------------------------------------------------------------
/backend/src/core/wsgi.py:
--------------------------------------------------------------------------------
1 | """
2 | WSGI config for olivin_store project.
3 |
4 | It exposes the WSGI callable as a module-level variable named ``application``.
5 |
6 | For more information on this file, see
7 | https://docs.djangoproject.com/en/5.0/howto/deployment/wsgi/
8 | """
9 |
10 | import os
11 |
12 | from django.core.wsgi import get_wsgi_application
13 |
14 | os.environ.setdefault(
15 | "DJANGO_SETTINGS_MODULE",
16 | os.getenv("DJANGO_SETTINGS_MODULE", "src.core.settings.dev"),
17 | )
18 |
19 | application = get_wsgi_application()
20 |
--------------------------------------------------------------------------------
/backend/src/data/clients/__init__.py:
--------------------------------------------------------------------------------
1 | from src.data.clients.amazon_client import AmazonClient
2 | from src.data.clients.celery_client import CeleryClient
3 | from src.data.clients.mail_client import MailClient
4 | from src.data.clients.minio_client import MinioClient
5 | from src.data.clients.redis_client import RedisClient
6 | from src.data.clients.vonage_client import VonageClient
7 |
8 | __all__ = [
9 | "VonageClient",
10 | "MailClient",
11 | "RedisClient",
12 | "CeleryClient",
13 | "MinioClient",
14 | "AmazonClient",
15 | ]
16 |
--------------------------------------------------------------------------------
/frontend/src/main.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom/client";
3 | import App from "./App.tsx";
4 | import "./index.css";
5 | import { DevSupport } from "@react-buddy/ide-toolbox";
6 | import { ComponentPreviews, useInitial } from "./dev";
7 |
8 | ReactDOM.createRoot(document.getElementById("root")!).render(
9 |
10 |
14 |
15 |
16 | ,
17 | );
18 |
--------------------------------------------------------------------------------
/backend/src/users/errors/profile_error.py:
--------------------------------------------------------------------------------
1 | from ninja_extra import status
2 | from ninja_extra.exceptions import APIException
3 |
4 |
5 | class ProfileDoesNotExist(APIException):
6 | default_detail = "Profile does not exist"
7 | status_code = status.HTTP_404_NOT_FOUND
8 |
9 |
10 | class ProfileNotFound(APIException):
11 | default_detail = "Profile not found"
12 | status_code = status.HTTP_404_NOT_FOUND
13 |
14 |
15 | class PhoneAlreadyExists(APIException):
16 | default_detail = "Phone already exists"
17 | status_code = status.HTTP_400_BAD_REQUEST
18 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | repos:
2 | - repo: https://github.com/pre-commit/pre-commit-hooks
3 | rev: v2.3.0
4 | hooks:
5 | - id: check-yaml
6 | - id: end-of-file-fixer
7 | - id: trailing-whitespace
8 | - repo: https://github.com/astral-sh/ruff-pre-commit
9 | rev: v0.3.5
10 | hooks:
11 | - id: ruff
12 | files: ^backend/src
13 | args: [--fix]
14 | - id: ruff-format
15 | - repo: https://github.com/pre-commit/mirrors-prettier
16 | rev: v4.0.0-alpha.8
17 | hooks:
18 | - id: prettier
19 | files: ^frontend/src
20 |
--------------------------------------------------------------------------------
/backend/src/common/permissions.py:
--------------------------------------------------------------------------------
1 | from ninja_extra.exceptions import APIException
2 | from ninja_extra.permissions.common import BasePermission
3 |
4 |
5 | class IsOwner(BasePermission):
6 | def has_permission(self, request, *args, **kwargs):
7 | if request.auth["username"]: # type: ignore
8 | return True
9 |
10 | return False
11 |
12 |
13 | class LoggedOutOnly(BasePermission):
14 | def has_permission(self, request, *args, **kwargs):
15 | if request.user.is_authenticated:
16 | raise APIException("Already logged in")
17 |
18 | return True
19 |
--------------------------------------------------------------------------------
/commands/dev/backend/delete_migrations_files.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | set -o errexit
3 | set -o pipefail
4 | set -o nounset
5 |
6 | delete_migrations() {
7 | local directory="$1"
8 | if [ -d "$directory" ]; then
9 | echo "Deleting files in directory: $directory"
10 | find "$directory" -type f ! -name '__init__.py' -delete
11 | fi
12 | }
13 |
14 | find "$PWD/backend/src" -type d -name 'migrations' | while read -r migrations_dir; do
15 | delete_migrations "$migrations_dir"
16 | done
17 |
18 |
19 | ./commands/dev/backend/delete_volume.sh postgres-olivin olivin_store_postgres_data
20 |
--------------------------------------------------------------------------------
/backend/docker/celery/flower.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -o errexit
4 | set -o nounset
5 |
6 | sleep 5
7 |
8 | export CELERY_BROKER_URL="redis://:${REDIS_PASSWORD}@${REDIS_HOST}:${REDIS_PORT}/${REDIS_DB}"
9 |
10 | worker_ready() {
11 | celery -A src.core.celery inspect ping
12 | }
13 |
14 | until worker_ready; do
15 | >&2 echo 'Celery workers not available'
16 | sleep 1
17 | done
18 | >&2 echo 'Celery workers is available'
19 |
20 | celery \
21 | -A src.core.celery \
22 | -b "${CELERY_BROKER_URL}" \
23 | flower \
24 | --basic_auth="${CELERY_FLOWER_USER}:${CELERY_FLOWER_PASSWORD}"
25 |
--------------------------------------------------------------------------------
/backend/templates/register-mail.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Registration Confirmation
7 |
8 |
9 |
10 |
Welcome to Our Platform!
11 |
Thank you for registering with us.
12 |

13 |
Click here to continue.
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/frontend/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 | ### ReactNative template
26 | # React Native Stack Base
27 |
28 | .expo
29 | __generated__
30 |
31 | ### react template
32 | .DS_*
33 | **/*.backup.*
34 | **/*.back.*
35 |
36 | bower_components
37 |
38 | *.sublime*
39 |
40 | psd
41 | thumb
42 | sketch
43 |
--------------------------------------------------------------------------------
/backend/src/users/validations/__init__.py:
--------------------------------------------------------------------------------
1 | from src.users.validations.profile_validation import (
2 | validate_birth_date,
3 | validate_code,
4 | validate_phone,
5 | )
6 | from src.users.validations.user_validation import (
7 | check_passwords_match,
8 | validate_email,
9 | validate_password,
10 | validate_username,
11 | )
12 |
13 | __all__ = [
14 | # User validations
15 | "check_passwords_match",
16 | "validate_email",
17 | "validate_password",
18 | "validate_username",
19 | # Profile validations
20 | "validate_phone",
21 | "validate_birth_date",
22 | "validate_code",
23 | ]
24 |
--------------------------------------------------------------------------------
/frontend/src/dev/palette.tsx:
--------------------------------------------------------------------------------
1 | import { Fragment } from "react";
2 | import {
3 | Category,
4 | Component,
5 | Variant,
6 | Palette,
7 | } from "@react-buddy/ide-toolbox";
8 | import AntdPalette from "@react-buddy/palette-antd";
9 |
10 | export const PaletteTree = () => (
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | );
22 |
23 | export function ExampleLoaderComponent() {
24 | return Loading...;
25 | }
26 |
--------------------------------------------------------------------------------
/backend/src/users/enums.py:
--------------------------------------------------------------------------------
1 | from enum import Enum
2 |
3 | from src.core.config import BASE_DIR
4 | from src.users.files import read_country_codes
5 |
6 | COUNTRY_CODES = read_country_codes(BASE_DIR / "media" / "files" / "country_codes.csv")
7 |
8 |
9 | def create_country_enum():
10 | enum = Enum(
11 | "CountryEnum",
12 | read_country_codes(BASE_DIR / "media" / "files" / "country_codes.csv"),
13 | )
14 |
15 | def choices(cls):
16 | return [(i.name, i.value) for i in cls]
17 |
18 | setattr(enum, "choices", classmethod(choices))
19 |
20 | return enum
21 |
22 |
23 | CountryCodeEnum = create_country_enum()
24 |
--------------------------------------------------------------------------------
/backend/README.md:
--------------------------------------------------------------------------------
1 | ## Steps:
2 |
3 | ### Install
4 |
5 | ```bash
6 | # install uv
7 | pip install uv
8 | ```
9 |
10 | ```bash
11 | # create virtual environment
12 | uv venv
13 | ```
14 |
15 | ```bash
16 | # install pack-name
17 | uv pip install
18 | ```
19 |
20 | ```bash
21 | # add installations to requirements
22 | uv pip freeze | uv pip compile - -o requirements.txt
23 | ```
24 |
25 | ```bash
26 | # sync requirements is good package
27 | uv pip sync requirements.txt
28 | ```
29 |
30 | ```bash
31 | # install all from requirements
32 | uv pip install -r requirements.txt
33 | ```
34 |
35 | ```bash
36 | # sync config with VM
37 | uv pip sync requirements.txt
38 | ```
39 |
--------------------------------------------------------------------------------
/frontend/src/dev/useInitial.ts:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { InitialHookStatus } from "@react-buddy/ide-toolbox";
3 |
4 | export const useInitial: () => InitialHookStatus = () => {
5 | const [status, setStatus] = useState({
6 | loading: false,
7 | error: false,
8 | });
9 | /*
10 | Implement hook functionality here.
11 | If you need to execute async operation, set loading to true and when it's over, set loading to false.
12 | If you caught some errors, set error status to true.
13 | Initial hook is considered to be successfully completed if it will return {loading: false, error: false}.
14 | */
15 | return status;
16 | };
17 |
--------------------------------------------------------------------------------
/.envs/dev/django.env:
--------------------------------------------------------------------------------
1 | # basic
2 | # ------------------------------------------------------------------------------
3 | DJANGO_SETTINGS_MODULE=src.core.settings.dev
4 | DEBUG=1
5 | SECRET_KEY=test_secret_key
6 | ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0
7 | DJANGO_HOST=0.0.0.0
8 | DJANGO_PORT=8000
9 | # admin auth
10 | # ------------------------------------------------------------------------------
11 | DJANGO_SUPERUSER_EMAIL=admin@admin.com
12 | DJANGO_SUPERUSER_USERNAME=admin
13 | DJANGO_SUPERUSER_PASSWORD=admin
14 | # algorithm jwt
15 | ALGORITTHM=HS256
16 | # debug
17 | PYTHONBREAKPOINT=pdb.set_trace
18 | # DEBUG FLAG
19 | DEBUG_ON=0
20 | ACCESS_TOKEN_EXPIRE=60
21 | REFRESH_TOKEN_EXPIRE=1440
22 |
--------------------------------------------------------------------------------
/frontend/src/routes.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | createBrowserRouter,
3 | createRoutesFromElements,
4 | Route,
5 | } from "react-router-dom";
6 | import Header from "./components/nav/Header.tsx";
7 | import HomePage from "./pages/HomePage/HomePage.tsx";
8 | import { LoginPage } from "./pages/auth/LoginPage";
9 | import { RegisterPage } from "./pages/auth/RegisterPage";
10 |
11 | const router = createBrowserRouter(
12 | createRoutesFromElements(
13 | }>
14 | } />
15 | } />
16 | } />
17 | ,
18 | ),
19 | );
20 |
21 | export default router;
22 |
--------------------------------------------------------------------------------
/backend/src/common/responses.py:
--------------------------------------------------------------------------------
1 | import orjson
2 | from django.http.response import HttpResponse
3 |
4 |
5 | class ORJSONResponse(HttpResponse):
6 | def __init__(self, data, status, encoder=None, safe=True, **kwargs):
7 | if safe and data is not None and not isinstance(data, dict):
8 | raise TypeError(
9 | "In order to allow non-dict objects to be serialized set the "
10 | "safe parameter to False."
11 | )
12 | if data is not None:
13 | data = orjson.dumps(data, default=encoder).decode("utf-8")
14 | kwargs["content_type"] = "application/json"
15 | self.status_code = status
16 | super().__init__(content=data, status=status, **kwargs)
17 |
--------------------------------------------------------------------------------
/backend/src/data/interfaces/handler/abstract_phone.py:
--------------------------------------------------------------------------------
1 | from abc import ABC, abstractmethod
2 | from typing import Optional
3 |
4 |
5 | class IPhoneHandler(ABC):
6 | @abstractmethod
7 | def send_sms(
8 | self,
9 | number: str,
10 | message: str,
11 | title: str = "Olivin Store",
12 | **kwargs,
13 | ) -> bool:
14 | pass
15 |
16 | @abstractmethod
17 | def verify_number(
18 | self,
19 | number: str,
20 | brand: str,
21 | **kwargs,
22 | ) -> Optional[str]:
23 | pass
24 |
25 | @abstractmethod
26 | def verify_number_code(
27 | self,
28 | request_id: str,
29 | code: str,
30 | **kwargs,
31 | ) -> bool:
32 | pass
33 |
--------------------------------------------------------------------------------
/frontend/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vite";
2 | import react from "@vitejs/plugin-react";
3 | import tsconfigPaths from "vite-tsconfig-paths";
4 | import dotenv from "dotenv";
5 |
6 | dotenv.config({ path: __dirname + "../../.envs/dev/react.env" });
7 |
8 | const hostVal = process.env.HOST ? process.env.HOST.toString() : "0.0.0.0";
9 | const portVal = process.env.PORT ? Number(process.env.PORT) : 3000;
10 |
11 | // https://vitejs.dev/config/
12 | export default defineConfig({
13 | plugins: [react(), tsconfigPaths()],
14 | server: {
15 | watch: {
16 | usePolling: true,
17 | },
18 | host: hostVal,
19 | strictPort: true,
20 | port: portVal,
21 | },
22 | define: {
23 | "process.env": process.env,
24 | },
25 | });
26 |
--------------------------------------------------------------------------------
/backend/src/users/schemas/schema.py:
--------------------------------------------------------------------------------
1 | from pydantic import ConfigDict
2 |
3 | from src.users.schemas.profile_schema import ProfileUpdateSchema
4 | from src.users.schemas.user_schema import UserUpdateSchema
5 |
6 |
7 | class UserProfileUpdateSchema(UserUpdateSchema, ProfileUpdateSchema):
8 | model_config = ConfigDict(
9 | json_schema_extra={
10 | "description": "User and profile update schema",
11 | "title": "UserProfileUpdateSchema",
12 | "example": {
13 | "username": "new_username",
14 | "first_name": "new_first_name",
15 | "last_name": "new_last_name",
16 | "birth_date": "1990-02-02",
17 | "phone": "+48510100100",
18 | },
19 | }
20 | )
21 |
--------------------------------------------------------------------------------
/backend/src/common/controllers/common_controller.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import uuid
3 | from datetime import timedelta
4 |
5 | from django.http import JsonResponse
6 | from ninja import File, UploadedFile
7 | from ninja_extra import api_controller, http_get, http_post
8 |
9 | from src.common.tasks import (
10 | multiply,
11 | )
12 |
13 | logger = logging.getLogger(__name__)
14 |
15 |
16 | @api_controller(tags=["common"])
17 | class CommonController:
18 | @http_get("/")
19 | def get(self, request):
20 | return JsonResponse({"message": "hello"})
21 |
22 | @http_get("/task2")
23 | def task2(self):
24 | result = multiply.delay(2, 3)
25 | return JsonResponse(
26 | {
27 | "result": result,
28 | }
29 | )
30 |
--------------------------------------------------------------------------------
/frontend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "useDefineForClassFields": true,
5 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 | "baseUrl": "src",
9 |
10 | /* Bundler mode */
11 | "moduleResolution": "bundler",
12 | "allowImportingTsExtensions": true,
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "noEmit": true,
16 | "jsx": "react-jsx",
17 |
18 | /* Linting */
19 | "strict": true,
20 | "noUnusedLocals": true,
21 | "noUnusedParameters": true,
22 | "noFallthroughCasesInSwitch": true
23 | },
24 | "include": ["src"],
25 | "exclude": ["node_modules", "build", "dist"],
26 | "references": [{ "path": "./tsconfig.node.json" }]
27 | }
28 |
--------------------------------------------------------------------------------
/backend/src/events/models.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 |
3 | from django.db import models
4 |
5 | # Create your models here.
6 |
7 |
8 | class Event(models.Model):
9 | id = models.AutoField(
10 | primary_key=True,
11 | editable=False,
12 | )
13 | name = models.CharField(
14 | max_length=100,
15 | unique=True,
16 | null=False,
17 | blank=False,
18 | )
19 | start_date = models.DateField(
20 | null=True,
21 | blank=True,
22 | )
23 | end_date = models.DateField(
24 | null=True,
25 | blank=True,
26 | )
27 |
28 | def is_active(self):
29 | now = datetime.now().date()
30 | return self.start_date <= now <= self.end_date
31 |
32 | def __str__(self):
33 | return f"Event: {self.name}"
34 |
--------------------------------------------------------------------------------
/backend/src/data/interfaces/handler/abstract_event.py:
--------------------------------------------------------------------------------
1 | from abc import ABC, abstractmethod
2 | from typing import Callable, Optional, Union
3 |
4 |
5 | class IEventHandler(ABC):
6 | @abstractmethod
7 | def start_handlers(self) -> None:
8 | pass
9 |
10 | @abstractmethod
11 | def start_subscribers(self) -> None:
12 | pass
13 |
14 | @abstractmethod
15 | def publish(self, event_name: str, event_data: Union[str, dict]) -> None:
16 | pass
17 |
18 | @abstractmethod
19 | def subscribe(self, event_name: str) -> None:
20 | pass
21 |
22 | @abstractmethod
23 | def receive(
24 | self,
25 | event_name: str,
26 | timeout: Optional[None | float] = None,
27 | with_subscription: bool = False,
28 | ) -> Optional[dict]:
29 | pass
30 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Olivin Store
2 |
3 | ##### Project assumptions:
4 | Application created with Vite using React, React Query, and Poetry using Python and Django-Ninja tools.
5 | It is a service application using modern django orm management.
6 |
7 | The initial assumption is that the project is to have:
8 | - user authorization
9 | - user management
10 | - email system
11 | - photo storage using amazon or minio s3
12 | - sql database
13 | - celery queue system with events communicate
14 | - payload with preorder
15 | - product management
16 | - event management (promotions temporarily change the prices of selected event products)
17 | - add react dark - light mode
18 | - react calendar to chose delivery time
19 | - add dependency injection using request.context.auth
20 | - pub/sub system
21 | - add period/contrib/solar tasks
22 | - product rating
23 |
--------------------------------------------------------------------------------
/backend/src/auth/schemas/__init__.py:
--------------------------------------------------------------------------------
1 | from src.auth.schemas.auth_schema import (
2 | LoginSchema,
3 | LoginSchemaFailed,
4 | LoginSchemaSuccess,
5 | PasswordsMatchSchema,
6 | RefreshTokenSchema,
7 | RefreshTokenSchemaFailed,
8 | RefreshTokenSchemaSuccess,
9 | RegisterSchema,
10 | RegisterSuccessSchema,
11 | RegisterUrlSchema,
12 | RegisterUserMailSchema,
13 | UserCreateFailedSchema,
14 | )
15 |
16 | __all__ = [
17 | "RegisterSchema",
18 | "LoginSchema",
19 | "RefreshTokenSchema",
20 | "PasswordsMatchSchema",
21 | "RegisterSuccessSchema",
22 | "UserCreateFailedSchema",
23 | "LoginSchemaSuccess",
24 | "LoginSchemaFailed",
25 | "RefreshTokenSchemaSuccess",
26 | "RefreshTokenSchemaFailed",
27 | "RegisterUserMailSchema",
28 | "RegisterUrlSchema",
29 | ]
30 |
--------------------------------------------------------------------------------
/backend/manage.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | """Django's command-line utility for administrative tasks."""
3 |
4 | import os
5 | import sys
6 |
7 |
8 | def main():
9 | """Run administrative tasks."""
10 | os.environ.setdefault(
11 | "DJANGO_SETTINGS_MODULE",
12 | os.getenv("DJANGO_SETTINGS_MODULE", "src.core.settings.dev"),
13 | )
14 | try:
15 | from django.core.management import execute_from_command_line
16 | except ImportError as exc:
17 | raise ImportError(
18 | "Couldn't import Django. Are you sure it's installed and "
19 | "available on your PYTHONPATH environment variable? Did you "
20 | "forget to activate a virtual environment?"
21 | ) from exc
22 | execute_from_command_line(sys.argv)
23 |
24 |
25 | if __name__ == "__main__":
26 | main()
27 |
--------------------------------------------------------------------------------
/backend/src/data/handlers/__init__.py:
--------------------------------------------------------------------------------
1 | from src.data.handlers.event_handler import EventHandler
2 | from src.data.handlers.file_handler import (
3 | AvatarFileHandler,
4 | ImageFileHandler,
5 | MediaFileHandler,
6 | ProductFileHandler,
7 | )
8 | from src.data.handlers.mail_handler import RegistrationEmailHandler
9 | from src.data.handlers.phone_handler import FakePhoneHandler, VonagePhoneHandler
10 | from src.data.handlers.redis_handler import CacheHandler
11 | from src.data.handlers.template_handler import TemplateHandler
12 |
13 | __all__ = [
14 | "VonagePhoneHandler",
15 | "FakePhoneHandler",
16 | "EventHandler",
17 | "CacheHandler",
18 | "AvatarFileHandler",
19 | "ProductFileHandler",
20 | "RegistrationEmailHandler",
21 | "TemplateHandler",
22 | "MediaFileHandler",
23 | "ImageFileHandler",
24 | ]
25 |
--------------------------------------------------------------------------------
/backend/src/data/interfaces/storage/abstract_cache.py:
--------------------------------------------------------------------------------
1 | from abc import ABC, abstractmethod
2 | from datetime import timedelta
3 | from typing import Any, Optional
4 |
5 |
6 | class ICacheStorage(ABC):
7 | @abstractmethod
8 | def set(
9 | self,
10 | key: Any,
11 | value: Any,
12 | expire: Optional[int | timedelta] = None,
13 | ) -> bool:
14 | pass
15 |
16 | @abstractmethod
17 | def get(
18 | self,
19 | key: Any,
20 | ) -> str:
21 | pass
22 |
23 | @abstractmethod
24 | def delete(
25 | self,
26 | key: Any,
27 | ) -> bool:
28 | pass
29 |
30 | @abstractmethod
31 | def flush(self) -> bool:
32 | pass
33 |
34 | @abstractmethod
35 | def exists(
36 | self,
37 | key: Any,
38 | ) -> bool:
39 | pass
40 |
--------------------------------------------------------------------------------
/backend/src/files/errors.py:
--------------------------------------------------------------------------------
1 | from ninja_extra import status
2 | from ninja_extra.exceptions import APIException
3 |
4 |
5 | class AvatarNotFound(APIException):
6 | default_detail = "Avatar not found"
7 | status_code = status.HTTP_404_NOT_FOUND
8 |
9 |
10 | class AvatarExists(APIException):
11 | default_detail = "Avatar already exists"
12 | status_code = status.HTTP_400_BAD_REQUEST
13 |
14 |
15 | class AvatarUploadFailed(APIException):
16 | default_detail = "Avatar upload failed"
17 | status_code = status.HTTP_400_BAD_REQUEST
18 |
19 |
20 | class AvatarDeleteFailed(APIException):
21 | default_detail = "Avatar deletion failed"
22 | status_code = status.HTTP_400_BAD_REQUEST
23 |
24 |
25 | class AvatarUpdateFailed(APIException):
26 | default_detail = "Avatar update failed"
27 | status_code = status.HTTP_400_BAD_REQUEST
28 |
--------------------------------------------------------------------------------
/frontend/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "root": true,
3 | "env": {
4 | "browser": true,
5 | "es2020": true
6 | },
7 | "extends": [
8 | "eslint:recommended",
9 | "plugin:react/recommended",
10 | "plugin:react/jsx-runtime",
11 | "plugin:@typescript-eslint/recommended",
12 | "plugin:@typescript-eslint/stylistic-type-checked",
13 | "plugin:react-hooks/recommended"
14 | ],
15 | "ignorePatterns": ["dist", ".eslintrc.cjs"],
16 | "parser": "@typescript-eslint/parser",
17 | "plugins": ["react-refresh"],
18 | "rules": {
19 | "react-refresh/only-export-components": [
20 | "warn",
21 | { "allowConstantExport": true }
22 | ]
23 | },
24 | "parserOptions": {
25 | "ecmaVersion": "latest",
26 | "sourceType": "module",
27 | "project": ["./tsconfig.json", "./tsconfig.node.json"],
28 | "tsconfigRootDir": "./"
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/backend/src/data/handlers/template_handler.py:
--------------------------------------------------------------------------------
1 | from logging import getLogger
2 | from typing import Any, TypeVar, Union
3 | from uuid import UUID
4 |
5 | from src.data.interfaces import ICloudStorage
6 |
7 | logger = getLogger(__name__)
8 | ObjectType = TypeVar("ObjectType", bound=Union[UUID, str, int])
9 |
10 |
11 | class TemplateHandler:
12 | def __init__(self, storage: ICloudStorage, folder: str = "templates"):
13 | self.storage = storage
14 | self.folder = folder
15 |
16 | def upload_template(self, template_name: str) -> bool:
17 | return self.storage.upload_file_from_path(
18 | filename=template_name,
19 | folder=self.folder,
20 | )
21 |
22 | def get_template(self, template_name: str) -> Any:
23 | return self.storage.get_file(
24 | filename=template_name,
25 | folder=self.folder,
26 | )
27 |
--------------------------------------------------------------------------------
/frontend/src/dev/previews.tsx:
--------------------------------------------------------------------------------
1 | import { ComponentPreview, Previews } from "@react-buddy/ide-toolbox";
2 | import { PaletteTree } from "./palette";
3 | import { ButtonLightDark, Header } from "../components/nav";
4 | import HomePage from "../pages/HomePage/HomePage.tsx";
5 | import { LoginPage } from "../pages/auth/LoginPage";
6 |
7 | const ComponentPreviews = () => {
8 | return (
9 | }>
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | );
24 | };
25 |
26 | export default ComponentPreviews;
27 |
--------------------------------------------------------------------------------
/backend/src/data/interfaces/__init__.py:
--------------------------------------------------------------------------------
1 | from src.data.interfaces.client.abstract_client import IClient
2 | from src.data.interfaces.handler.abstract_cache import ICacheHandler
3 | from src.data.interfaces.handler.abstract_event import IEventHandler
4 | from src.data.interfaces.handler.abstract_file import IFileHandler
5 | from src.data.interfaces.handler.abstract_mail import IRegistrationEmailHandler
6 | from src.data.interfaces.handler.abstract_phone import IPhoneHandler
7 | from src.data.interfaces.managers.abstract_event_manager import IEventManager
8 | from src.data.interfaces.storage.abstract_cache import ICacheStorage
9 | from src.data.interfaces.storage.abstract_cloud import ICloudStorage
10 |
11 | __all__ = [
12 | "IClient",
13 | "ICacheHandler",
14 | "IFileHandler",
15 | "ICacheStorage",
16 | "ICloudStorage",
17 | "IRegistrationEmailHandler",
18 | "IEventManager",
19 | "IEventHandler",
20 | "IPhoneHandler",
21 | ]
22 |
--------------------------------------------------------------------------------
/backend/docker/scripts/wait_for_port.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | wait_for_port() {
4 | local host=$1
5 | local port=$2
6 | local timeout=${3:-30}
7 | # shellcheck disable=SC2155
8 | local start_time=$(date +%s)
9 |
10 | while true; do
11 | if nc -z -w 1 "$host" "$port" >/dev/null 2>&1; then
12 | echo "Port $port is open on $host"
13 | return 0
14 | else
15 | local elapsed_time=$(($(date +%s) - "$start_time"))
16 | if [ "$elapsed_time" -ge "$timeout" ]; then
17 | echo "Timeout waiting for $host:$port"
18 | return 1
19 | fi
20 | sleep 1
21 | fi
22 | done
23 | }
24 |
25 | if [ $# -ne 2 ]; then
26 | echo "Usage: $0 "
27 | exit 1
28 | fi
29 |
30 | host=$1
31 | port=$2
32 |
33 | if wait_for_port "$host" "$port"; then
34 | echo "Continuing with the script..."
35 | else
36 | echo "Exiting the script."
37 | exit 1
38 | fi
39 |
--------------------------------------------------------------------------------
/frontend/src/providers/ThemeProvider.tsx:
--------------------------------------------------------------------------------
1 | import { useState, ReactNode, useMemo } from "react";
2 | import { ConfigProvider, ThemeConfig, theme as AntdTheme } from "antd";
3 | import { ThemeContext } from "contexts/ThemeContext";
4 |
5 | export const ThemeProvider = ({ children }: { children: ReactNode }) => {
6 | const { defaultAlgorithm, darkAlgorithm } = AntdTheme;
7 | const [theme, setTheme] = useState("light");
8 |
9 | const toggleTheme = () => {
10 | setTheme((prevState) => (prevState === "light" ? "dark" : "light"));
11 | };
12 |
13 | const themeConfig: ThemeConfig = {
14 | algorithm: theme === "light" ? defaultAlgorithm : darkAlgorithm,
15 | };
16 |
17 | const contextValue = useMemo(
18 | () => ({
19 | theme,
20 | toggleTheme,
21 | }),
22 | [theme],
23 | );
24 |
25 | return (
26 |
27 | {children}
28 |
29 | );
30 | };
31 |
--------------------------------------------------------------------------------
/backend/src/auth/tasks.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | from celery.exceptions import MaxRetriesExceededError
4 |
5 | from src.core.celery import celery
6 | from src.data.clients import MailClient
7 | from src.data.handlers import RegistrationEmailHandler
8 | from src.data.managers import MailManager
9 |
10 | logger = logging.getLogger(name="celery")
11 | logger.setLevel(logging.DEBUG)
12 |
13 |
14 | @celery.task(queue="tasks")
15 | def send_registration_email_task(url: str, image: str, email: str):
16 | logger.info("Sending registration email")
17 | try:
18 | mail_handler = RegistrationEmailHandler(
19 | manager=MailManager(client=MailClient())
20 | )
21 | except Exception as error:
22 | logger.error(f"Failed to initialize mail handler: {str(error)}")
23 | raise Exception("Failed to initialize mail handler")
24 | mail_handler.send_registration_email(
25 | to_email=email, context={"url": url, "image": image}
26 | )
27 | logger.info("Registration email sent")
28 |
--------------------------------------------------------------------------------
/backend/src/users/errors/__init__.py:
--------------------------------------------------------------------------------
1 | from src.users.errors.profile_error import (
2 | PhoneAlreadyExists,
3 | ProfileDoesNotExist,
4 | ProfileNotFound,
5 | )
6 | from src.users.errors.user_error import (
7 | EmailAlreadyExists,
8 | EmailAlreadyInUse,
9 | EmailDoesNotExist,
10 | EmailUpdateFailed,
11 | SuperUserCreateFailed,
12 | UserCreateFailed,
13 | UserDoesNotExist,
14 | UsernameAlreadyExists,
15 | UserNotFound,
16 | UserUpdateFailed,
17 | WrongOldEmail,
18 | WrongPassword,
19 | )
20 |
21 | __all__ = [
22 | # user
23 | "UsernameAlreadyExists",
24 | "EmailAlreadyExists",
25 | "UserDoesNotExist",
26 | "WrongPassword",
27 | "UserNotFound",
28 | "EmailDoesNotExist",
29 | "UserCreateFailed",
30 | "SuperUserCreateFailed",
31 | "EmailAlreadyInUse",
32 | "WrongOldEmail",
33 | "EmailUpdateFailed",
34 | "UserUpdateFailed",
35 | # profile
36 | "ProfileDoesNotExist",
37 | "ProfileNotFound",
38 | "PhoneAlreadyExists",
39 | ]
40 |
--------------------------------------------------------------------------------
/backend/src/data/interfaces/managers/abstract_event_manager.py:
--------------------------------------------------------------------------------
1 | from abc import ABC, abstractmethod
2 | from typing import Optional
3 |
4 |
5 | class IEventManager(ABC):
6 | @abstractmethod
7 | def publish(
8 | self,
9 | event_name: str,
10 | event_data: str | dict,
11 | ) -> None:
12 | pass
13 |
14 | @abstractmethod
15 | def subscribe(
16 | self,
17 | event_name: Optional[str] = None,
18 | event_list: Optional[list[str]] = None,
19 | ) -> None:
20 | pass
21 |
22 | @abstractmethod
23 | def unsubscribe(
24 | self,
25 | event_name: str,
26 | event_list: Optional[list[str]] = None,
27 | ) -> None:
28 | pass
29 |
30 | @abstractmethod
31 | def receive(
32 | self,
33 | event_name: str,
34 | timeout: Optional[None | float] = None,
35 | ) -> Optional[dict]:
36 | pass
37 |
38 | @abstractmethod
39 | def is_subscribed(
40 | self,
41 | event_name: str,
42 | ) -> bool:
43 | pass
44 |
--------------------------------------------------------------------------------
/backend/src/core/celery.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import logging.config
3 | from typing import TypeVar
4 |
5 | from celery import Celery
6 | from celery.signals import setup_logging
7 | from django.conf import settings
8 |
9 | from src.data.clients import CeleryClient
10 |
11 | logger = logging.getLogger(__name__)
12 | CeleryType = TypeVar("CeleryType", bound=Celery)
13 |
14 |
15 | def get_celery() -> CeleryType:
16 | client = CeleryClient(
17 | main_settings=settings.CELERY_SETTINGS,
18 | broker_url=settings.CELERY_BROKER_URL,
19 | result_backend=settings.CELERY_RESULT_BACKEND,
20 | timezone=settings.TIME_ZONE,
21 | )
22 | return client.celery
23 |
24 |
25 | @setup_logging.connect
26 | def config_loggers(*args, **kwargs):
27 | logging.config.dictConfig(settings.LOGGING)
28 |
29 |
30 | celery = get_celery()
31 |
32 | if celery.connection:
33 | logger.info("Connected to celery broker")
34 | else:
35 | logger.error("Failed to connect to celery broker")
36 |
37 | celery.conf.beat_schedule = {
38 | **settings.CELERY_BEAT_SCHEDULE,
39 | }
40 |
--------------------------------------------------------------------------------
/backend/src/data/storages/redis_storage.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import os
3 | from datetime import timedelta
4 | from typing import Any, Optional
5 |
6 | from django.conf import settings
7 |
8 | from src.data.clients import RedisClient
9 | from src.data.interfaces import ICacheStorage
10 |
11 | logger = logging.getLogger(__name__)
12 |
13 |
14 | class RedisStorage(ICacheStorage):
15 | def __init__(self, **kwargs):
16 | self.storage = RedisClient().connect(**kwargs)
17 |
18 | def get(self, key: Any) -> Optional[Any]:
19 | return self.storage.get(name=key)
20 |
21 | def set(
22 | self, key: Any, value: Any, expire: Optional[int | timedelta] = None
23 | ) -> None:
24 | if not expire:
25 | expire = settings.REDIS_EXPIRE
26 |
27 | self.storage.set(name=key, value=value, ex=expire)
28 |
29 | def delete(self, key: Any) -> None:
30 | self.storage.delete(*key)
31 |
32 | def exists(self, key: Any) -> bool:
33 | return self.storage.exists(*key)
34 |
35 | def flush(self) -> None:
36 | self.storage.flushdb()
37 |
--------------------------------------------------------------------------------
/backend/src/data/clients/vonage_client.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import os
3 | from typing import Optional
4 |
5 | from django.conf import settings
6 | from vonage.client import Client
7 |
8 | from src.data.interfaces.client.abstract_client import IClient
9 |
10 | os.environ.setdefault(
11 | "DJANGO_SETTINGS_MODULE",
12 | os.getenv("DJANGO_SETTINGS_MODULE", "src.core.settings.dev"),
13 | )
14 |
15 | logger = logging.getLogger(__name__)
16 |
17 |
18 | class VonageClient(IClient):
19 | client: Optional[Client] = None
20 |
21 | def connect(
22 | self,
23 | api_key: str = settings.VONAGE_API_KEY,
24 | api_secret: str = settings.VONAGE_API_SECRET,
25 | ) -> Optional[Client]:
26 | try:
27 | client = Client(
28 | key=settings.VONAGE_API_KEY,
29 | secret=settings.VONAGE_API_SECRET,
30 | )
31 | return client
32 | except Exception as e:
33 | logger.exception("connect: %s", e)
34 | return None
35 |
36 | def disconnect(self) -> None:
37 | self.client = None
38 |
--------------------------------------------------------------------------------
/backend/src/orders/models/shipping_model.py:
--------------------------------------------------------------------------------
1 | from django.db import models
2 |
3 | from src.common.models import CreatedUpdatedDateModel
4 | from src.orders.models.order_model import Order
5 |
6 | # Create your models here.
7 |
8 |
9 | class Shipping(CreatedUpdatedDateModel):
10 | id = models.AutoField(
11 | primary_key=True,
12 | editable=False,
13 | )
14 | # relationships
15 | order = models.OneToOneField(
16 | Order,
17 | on_delete=models.SET_NULL,
18 | related_name="shipping",
19 | null=True,
20 | )
21 | city = models.CharField(
22 | max_length=100,
23 | )
24 | street = models.CharField(
25 | max_length=100,
26 | )
27 | building_number = models.CharField(
28 | max_length=100,
29 | )
30 | postal_code = models.CharField(
31 | max_length=100,
32 | )
33 | country_code = models.CharField(
34 | max_length=100,
35 | )
36 |
37 | def __str__(self):
38 | return f"{self.city}, {self.street}, {self.building_number}, {self.postal_code}, {self.country_code}"
39 |
--------------------------------------------------------------------------------
/backend/src/data/handlers/mail_handler.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from typing import Optional
3 |
4 | from django.conf import settings
5 |
6 | from src.data.interfaces import IRegistrationEmailHandler
7 | from src.data.managers.mail_manager import MailManager
8 |
9 | logger = logging.getLogger(__name__)
10 |
11 |
12 | class RegistrationEmailHandler(IRegistrationEmailHandler):
13 | def __init__(self, manager: MailManager):
14 | self.manager = manager
15 |
16 | def send_registration_email(
17 | self,
18 | to_email: str,
19 | subject: str = "Welcome to our platform!",
20 | template_name: str = "register-mail",
21 | from_email: Optional[str] = settings.EMAIL_HOST_USER,
22 | context: Optional[dict] = None,
23 | ) -> bool:
24 | to_email = [to_email]
25 | return self.manager.send_mail(
26 | subject=subject,
27 | template_name=template_name,
28 | context=context,
29 | to_email=to_email,
30 | from_email=from_email,
31 | files=None,
32 | fail_silently=False,
33 | )
34 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 radthenone
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/backend/src/users/models/profile_model.py:
--------------------------------------------------------------------------------
1 | import json
2 | import uuid
3 | from typing import Any, Optional
4 |
5 | from django.conf import settings
6 | from django.db import models
7 | from django.utils import timezone
8 |
9 | from src.common.mixins import ModelToDictToJsonMixin
10 |
11 |
12 | class Profile(models.Model, ModelToDictToJsonMixin):
13 | id = models.UUIDField(
14 | primary_key=True,
15 | default=uuid.uuid4,
16 | editable=False,
17 | )
18 | birth_date = models.DateField(
19 | null=True,
20 | blank=True,
21 | )
22 | phone = models.CharField(
23 | max_length=12,
24 | null=True,
25 | blank=True,
26 | )
27 | # relationships
28 | user = models.OneToOneField(
29 | to=settings.AUTH_USER_MODEL,
30 | on_delete=models.CASCADE,
31 | related_name="profile",
32 | )
33 | created_at = models.DateTimeField(
34 | db_index=True,
35 | default=timezone.now,
36 | )
37 | updated_at = models.DateTimeField(
38 | auto_now=True,
39 | )
40 |
41 | def __str__(self):
42 | return f"Profile of {self.user} "
43 |
--------------------------------------------------------------------------------
/backend/src/orders/models/item_model.py:
--------------------------------------------------------------------------------
1 | from django.db import models
2 |
3 | from src.common.models import CreatedUpdatedDateModel
4 | from src.orders.models.order_model import Order
5 | from src.products.models import Product
6 |
7 | # Create your models here.
8 |
9 |
10 | class OrderItem(CreatedUpdatedDateModel):
11 | id = models.AutoField(
12 | primary_key=True,
13 | editable=False,
14 | )
15 | product = models.ForeignKey(
16 | Product,
17 | on_delete=models.SET_NULL,
18 | related_name="order_items",
19 | null=True,
20 | )
21 | order = models.ForeignKey(
22 | Order,
23 | on_delete=models.SET_NULL,
24 | related_name="order_items",
25 | null=True,
26 | )
27 | name = models.CharField(
28 | max_length=100,
29 | null=True,
30 | blank=True,
31 | )
32 | qty = models.IntegerField(
33 | default=0,
34 | null=True,
35 | blank=True,
36 | )
37 | price = models.DecimalField(
38 | max_digits=10,
39 | decimal_places=2,
40 | null=True,
41 | blank=True,
42 | )
43 |
44 | def __str__(self):
45 | return self.name
46 |
--------------------------------------------------------------------------------
/backend/src/common/schemas/basic_schema.py:
--------------------------------------------------------------------------------
1 | from typing import Annotated
2 |
3 | from pydantic import (
4 | BaseModel,
5 | BeforeValidator,
6 | ConfigDict,
7 | model_validator,
8 | )
9 |
10 | from src.users.validations import (
11 | check_passwords_match,
12 | validate_password,
13 | )
14 |
15 |
16 | class CreatedAtSchema(BaseModel):
17 | created_at: str
18 |
19 |
20 | class UpdatedAtSchema(BaseModel):
21 | updated_at: str
22 |
23 |
24 | class MessageSchema(BaseModel):
25 | message: str
26 |
27 | model_config = ConfigDict(
28 | json_schema_extra={
29 | "required": ["message"],
30 | "properties": {
31 | "message": {
32 | "type": "string",
33 | },
34 | },
35 | }
36 | )
37 |
38 |
39 | class PasswordsMatchSchema(BaseModel):
40 | password: Annotated[str, BeforeValidator(validate_password)]
41 | rewrite_password: Annotated[str, BeforeValidator(validate_password)]
42 |
43 | @model_validator(mode="after")
44 | def passwords_match(self) -> "PasswordsMatchSchema":
45 | if check_passwords_match(self.password, self.rewrite_password):
46 | return self
47 |
--------------------------------------------------------------------------------
/backend/src/common/tasks.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from datetime import timedelta
3 |
4 | from src.core.celery import celery
5 | from src.data.managers.task_manager import TaskManager
6 |
7 | logger = logging.getLogger(__name__)
8 |
9 | task_manager = TaskManager(queue="tasks")
10 | task_manager_schedule = TaskManager(queue="beats")
11 |
12 |
13 | # @task_manager.add_task()
14 | # def multiply(a, b):
15 | # return a * b
16 |
17 |
18 | @celery.task(queue="tasks")
19 | def multiply(a, b):
20 | logging.info(f"multiply {a} {b}")
21 | return a * b
22 |
23 |
24 | @task_manager.add_task(queue="tasks")
25 | def multiply_interval(a, b):
26 | logging.info(f"multiply_interval {a} {b}")
27 | return a * b
28 |
29 |
30 | # @task_manager_schedule.add_periodic_task(
31 | # name="multiple by minute",
32 | # schedule_interval=timedelta(minutes=1),
33 | # )
34 | # def multiply_interval2(a, b):
35 | # logging.info(f"multiply_interval2 {a} {b}")
36 | # return a * b
37 | #
38 | #
39 | # @task_manager_schedule.add_periodic_task(
40 | # name="value return",
41 | # schedule_interval=timedelta(minutes=2),
42 | # )
43 | # def value_return(a, b):
44 | # logging.info(f"value_return {a / b}")
45 | # return a / b
46 |
--------------------------------------------------------------------------------
/commands/dev/backend/makemigrations.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | set -o errexit
3 | set -o pipefail
4 | set -o nounset
5 |
6 | if [ "$#" -gt 1 ]; then
7 | echo "Usage: $0 [--delete]" >&2
8 | exit 1
9 | fi
10 |
11 | if [ "$#" -eq 1 ] && [ "$1" != "--delete" ]; then
12 | echo "Usage: $0 [--delete]" >&2
13 | exit 1
14 | fi
15 |
16 | DELETE="${1:-}"
17 |
18 | if [ "$DELETE" = "--delete" ]; then
19 | docker-compose --profile backend stop db
20 | docker-compose --profile backend down -v db
21 | docker-compose --profile backend up --build -d db
22 | source ./commands/dev/backend/delete_migrations_files.sh
23 | source ./commands/dev/load-env.sh
24 | docker-compose --profile backend run --rm backend sh -c "python manage.py makemigrations"
25 | docker-compose --profile backend run --rm backend sh -c "python manage.py migrate"
26 | docker-compose --profile backend run --rm backend sh -c "python manage.py make_superuser --email '${DJANGO_SUPERUSER_EMAIL}' --password '${DJANGO_SUPERUSER_PASSWORD}'"
27 | else
28 | docker-compose --profile backend run --rm backend sh -c "python manage.py makemigrations"
29 | docker-compose --profile backend run --rm backend sh -c "python manage.py migrate"
30 | fi
31 |
32 | echo "Migrations created"
33 |
--------------------------------------------------------------------------------
/backend/src/data/interfaces/handler/abstract_cache.py:
--------------------------------------------------------------------------------
1 | from abc import ABC, abstractmethod
2 | from datetime import timedelta
3 | from typing import TYPE_CHECKING, Any, Optional, TypeVar
4 |
5 | if TYPE_CHECKING:
6 | from src.data.interfaces import ICacheStorage # unused import
7 |
8 | ICacheStorageType = TypeVar("ICacheStorageType", bound="ICacheStorage")
9 |
10 |
11 | class ICacheHandler(ABC):
12 | def __init__(self, pool_storage: ICacheStorageType, *args, **kwargs):
13 | self.pool_storage = pool_storage
14 |
15 | @abstractmethod
16 | def get_value(
17 | self,
18 | key: Any,
19 | ) -> Optional[Any]:
20 | pass
21 |
22 | @abstractmethod
23 | def set_value(
24 | self,
25 | key: Any,
26 | value: Any,
27 | expire: Optional[int | timedelta] = None,
28 | ) -> None:
29 | pass
30 |
31 | @abstractmethod
32 | def delete_value(
33 | self,
34 | key: Any,
35 | ) -> None:
36 | pass
37 |
38 | @abstractmethod
39 | def exists_all_values(
40 | self,
41 | key: Any,
42 | ) -> bool:
43 | pass
44 |
45 | @abstractmethod
46 | def delete_all_values(self) -> None:
47 | pass
48 |
--------------------------------------------------------------------------------
/backend/src/users/management/commands/make_superuser.py:
--------------------------------------------------------------------------------
1 | from django.core.management.base import BaseCommand, CommandError
2 |
3 | from src.users.models import User
4 |
5 |
6 | class Command(BaseCommand):
7 | def add_arguments(self, parser):
8 | parser.add_argument("--email", required=True, help="Superuser email")
9 | parser.add_argument("--password", required=True, help="Superuser password")
10 |
11 | def handle(self, *args, **options):
12 | email = options["email"]
13 | password = options["password"]
14 |
15 | if User.objects.filter(email=email).exists():
16 | raise CommandError(f"Superuser with email {email} already exists")
17 |
18 | try:
19 | user_db = User(
20 | email=email,
21 | password=password,
22 | is_staff=True,
23 | is_superuser=True,
24 | )
25 | user_db.set_password(user_db.password)
26 | user_db.save()
27 |
28 | self.stdout.write(
29 | self.style.SUCCESS(f"Superuser created with email {email}")
30 | )
31 | except Exception as error:
32 | raise CommandError(f"Error while creating superuser: {error}")
33 |
--------------------------------------------------------------------------------
/backend/src/reviews/models.py:
--------------------------------------------------------------------------------
1 | from django.db import models
2 |
3 | from src.common.models import CreatedUpdatedDateModel
4 | from src.products.models import Product
5 | from src.users.models import User
6 |
7 | # Create your models here.
8 |
9 |
10 | class Review(CreatedUpdatedDateModel):
11 | id = models.AutoField(
12 | primary_key=True,
13 | editable=False,
14 | )
15 | name = models.CharField(
16 | max_length=100,
17 | null=True,
18 | blank=True,
19 | )
20 | rating = models.IntegerField(
21 | default=0,
22 | null=True,
23 | blank=True,
24 | )
25 | comment = models.TextField(
26 | null=True,
27 | blank=True,
28 | )
29 | # relationships
30 | product = models.ForeignKey(
31 | Product,
32 | on_delete=models.SET_NULL,
33 | related_name="reviews",
34 | null=True,
35 | blank=True,
36 | )
37 | user = models.ForeignKey(
38 | User,
39 | on_delete=models.SET_NULL,
40 | related_name="reviews",
41 | null=True,
42 | blank=True,
43 | )
44 |
45 | class Meta:
46 | unique_together = [("product", "user")]
47 |
48 | def __str__(self):
49 | return self.name
50 |
--------------------------------------------------------------------------------
/backend/src/users/schemas/__init__.py:
--------------------------------------------------------------------------------
1 | from src.users.schemas.profile_schema import (
2 | PhoneCodeSchema,
3 | PhoneNumberSchema,
4 | ProfileCreateSchema,
5 | ProfileSchema,
6 | ProfileUpdateSchema,
7 | )
8 | from src.users.schemas.schema import UserProfileUpdateSchema
9 | from src.users.schemas.user_schema import (
10 | EmailUpdateErrorSchema,
11 | EmailUpdateSchema,
12 | EmailUpdateSuccessSchema,
13 | SuperUserCreateErrorSchema,
14 | SuperUserCreateSchema,
15 | SuperUserCreateSuccessSchema,
16 | UserCreateSchema,
17 | UserProfileErrorSchema,
18 | UserProfileSuccessSchema,
19 | UserSchema,
20 | UserUpdateSchema,
21 | )
22 |
23 | __all__ = [
24 | "UserProfileUpdateSchema",
25 | # User schemas
26 | "SuperUserCreateSchema",
27 | "UserUpdateSchema",
28 | "SuperUserCreateSuccessSchema",
29 | "SuperUserCreateErrorSchema",
30 | "EmailUpdateSchema",
31 | "EmailUpdateErrorSchema",
32 | "EmailUpdateSuccessSchema",
33 | "UserProfileSuccessSchema",
34 | "UserProfileErrorSchema",
35 | "UserSchema",
36 | "UserCreateSchema",
37 | # Profile schemas
38 | "ProfileSchema",
39 | "ProfileCreateSchema",
40 | "ProfileUpdateSchema",
41 | "PhoneNumberSchema",
42 | "PhoneCodeSchema",
43 | ]
44 |
--------------------------------------------------------------------------------
/backend/src/users/models/user_model.py:
--------------------------------------------------------------------------------
1 | import json
2 | import uuid
3 | from datetime import datetime
4 | from typing import Any, Optional
5 |
6 | from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin, UserManager
7 | from django.db import models
8 |
9 | from src.common.mixins import ModelToDictToJsonMixin
10 |
11 | # Create your models here.
12 |
13 |
14 | class User(AbstractBaseUser, PermissionsMixin, ModelToDictToJsonMixin):
15 | id = models.UUIDField(
16 | primary_key=True,
17 | default=uuid.uuid4,
18 | editable=False,
19 | )
20 | username = models.CharField(max_length=100, unique=True)
21 | password = models.CharField(max_length=100)
22 | email = models.EmailField(unique=True)
23 | first_name = models.CharField(max_length=100, null=True)
24 | last_name = models.CharField(max_length=100, null=True)
25 | is_staff = models.BooleanField(default=False)
26 | is_superuser = models.BooleanField(default=False)
27 | is_active = models.BooleanField(default=True)
28 | last_login = models.DateTimeField(auto_now=True)
29 | date_joined = models.DateTimeField(auto_now_add=True)
30 |
31 | USERNAME_FIELD = "email"
32 | REQUIRED_FIELDS = []
33 |
34 | objects = UserManager()
35 |
36 | def __str__(self):
37 | return f"User: {self.email}"
38 |
--------------------------------------------------------------------------------
/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "frontend",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "npx vite",
8 | "build": "npx tsc && npx vite build",
9 | "lint": "npx eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
10 | "lint:fix": "npm run lint -- --fix",
11 | "prettier": "npx prettier ./src --check",
12 | "prettier:fix": "npx prettier ./src --write",
13 | "preview": "npx vite preview"
14 | },
15 | "dependencies": {
16 | "antd": "^5.15.4",
17 | "dotenv": "^16.4.5",
18 | "react": "^18.2.0",
19 | "react-dom": "^18.2.0",
20 | "react-router-dom": "^6.22.3",
21 | "typescript": "^5.2.2",
22 | "vite": "^5.2.0",
23 | "vite-tsconfig-paths": "^4.3.2",
24 | "@react-buddy/ide-toolbox": "^2.4.0",
25 | "@react-buddy/palette-antd": "^5.3.0"
26 | },
27 | "devDependencies": {
28 | "@types/react": "^18.2.66",
29 | "@types/react-dom": "^18.2.22",
30 | "@typescript-eslint/eslint-plugin": "^7.2.0",
31 | "@typescript-eslint/parser": "^7.2.0",
32 | "@vitejs/plugin-react": "^4.2.1",
33 | "eslint": "^8.57.0",
34 | "eslint-plugin-react": "^7.34.1",
35 | "eslint-plugin-react-hooks": "^4.6.0",
36 | "eslint-plugin-react-refresh": "^0.4.6",
37 | "prettier": "^3.2.5"
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/backend/src/data/clients/redis_client.py:
--------------------------------------------------------------------------------
1 | import os
2 | from typing import Optional
3 |
4 | from django.conf import settings
5 | from redis import Redis
6 |
7 | from src.data.interfaces import IClient
8 |
9 | os.environ.setdefault(
10 | "DJANGO_SETTINGS_MODULE",
11 | os.getenv("DJANGO_SETTINGS_MODULE", "src.core.settings.dev"),
12 | )
13 |
14 |
15 | class RedisClient(IClient):
16 | redis: Optional[Redis] = None
17 |
18 | def __init__(
19 | self,
20 | host: str = settings.REDIS_HOST,
21 | port: int = settings.REDIS_PORT,
22 | password: str = settings.REDIS_PASSWORD,
23 | db: int = settings.REDIS_DB,
24 | decode_responses: bool = True,
25 | ):
26 | self.host = host
27 | self.port = port
28 | self.password = password
29 | self.db = db
30 | self.decode_responses = decode_responses
31 | self.redis = self.connect()
32 |
33 | def connect(self, **kwargs) -> Redis:
34 | self.redis = Redis(
35 | host=self.host,
36 | port=self.port,
37 | password=self.password,
38 | db=self.db,
39 | decode_responses=self.decode_responses,
40 | **kwargs,
41 | )
42 |
43 | return self.redis
44 |
45 | def disconnect(self, *args, **kwargs) -> None:
46 | if self.redis:
47 | self.redis.close()
48 |
--------------------------------------------------------------------------------
/frontend/README.md:
--------------------------------------------------------------------------------
1 | # React + TypeScript + Vite
2 |
3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
4 |
5 | Currently, two official plugins are available:
6 |
7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
9 |
10 | ## Expanding the ESLint configuration
11 |
12 | If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
13 |
14 | - Configure the top-level `parserOptions` property like this:
15 |
16 | ```js
17 | export default {
18 | // other rules...
19 | parserOptions: {
20 | ecmaVersion: "latest",
21 | sourceType: "module",
22 | project: ["./tsconfig.json", "./tsconfig.node.json"],
23 | tsconfigRootDir: __dirname,
24 | },
25 | };
26 | ```
27 |
28 | - Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
29 | - Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
30 | - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list
31 |
--------------------------------------------------------------------------------
/frontend/public/vite.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/backend/src/data/interfaces/handler/abstract_file.py:
--------------------------------------------------------------------------------
1 | from abc import ABC, abstractmethod
2 | from typing import TYPE_CHECKING, Optional, TypeVar, Union
3 | from uuid import UUID
4 |
5 | from ninja import UploadedFile
6 |
7 | if TYPE_CHECKING:
8 | from src.data.interfaces import ICloudStorage # unused import
9 |
10 | ICloudStorageType = TypeVar("ICloudStorageType", bound="ICloudStorage")
11 | ObjectType = TypeVar("ObjectType", bound=Union[UUID, str, int])
12 |
13 |
14 | class IFileHandler(ABC):
15 | def __init__(self, storage: ICloudStorageType, *args, **kwargs):
16 | self.storage = storage
17 |
18 | @abstractmethod
19 | def get_media(
20 | self,
21 | filename: str,
22 | object_key: Optional[ObjectType] = None,
23 | ) -> str:
24 | pass
25 |
26 | @abstractmethod
27 | def upload_media_from_url(
28 | self,
29 | filename: str,
30 | file: UploadedFile,
31 | object_key: Optional[ObjectType] = None,
32 | content_type: Optional[str] = None,
33 | ) -> bool:
34 | pass
35 |
36 | @abstractmethod
37 | def upload_media_from_path(
38 | self,
39 | filename: str,
40 | object_key: Optional[ObjectType] = None,
41 | content_type: Optional[str] = None,
42 | ) -> bool:
43 | pass
44 |
45 | @abstractmethod
46 | def delete_media(
47 | self,
48 | filename: str,
49 | object_key: Optional[ObjectType] = None,
50 | ) -> bool:
51 | pass
52 |
--------------------------------------------------------------------------------
/backend/docker/entrypoint.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | # if any of the commands in your code fails for any reason, the entire script fails
3 | set -o errexit
4 | # fail exit if one of your pipe command fails
5 | set -o pipefail
6 | # exits if any of your variables is not set
7 | set -o nounset
8 |
9 | if [ -z "${POSTGRES_USER}" ]; then
10 | base_postgres_image_default_user='postgres'
11 | export POSTGRES_USER="${base_postgres_image_default_user}"
12 | fi
13 |
14 | postgres_ready() {
15 | python << END
16 | import sys
17 | import time
18 | import psycopg2
19 | suggest_unrecoverable_after = 30
20 | start = time.time()
21 | while True:
22 | try:
23 | psycopg2.connect(
24 | dbname="${POSTGRES_DB}",
25 | user="${POSTGRES_USER}",
26 | password="${POSTGRES_PASSWORD}",
27 | host="${POSTGRES_HOST}",
28 | port="${POSTGRES_PORT}",
29 | )
30 | break
31 | except psycopg2.OperationalError as error:
32 | sys.stderr.write("Waiting for PostgreSQL to become available...\n")
33 | if time.time() - start > suggest_unrecoverable_after:
34 | sys.stderr.write(" This is taking longer than expected. The following exception may be indicative of an unrecoverable error: '{}'\n".format(error))
35 | time.sleep(1)
36 | END
37 | }
38 |
39 | until postgres_ready; do
40 | >&2 echo "Waiting for PostgreSQL to become available..."
41 | wait_for_port "${POSTGRES_HOST}" "${POSTGRES_PORT}"
42 | done
43 | >&2 echo 'PostgreSQL is available'
44 |
45 | exec "$@"
46 |
--------------------------------------------------------------------------------
/backend/src/auth/errors.py:
--------------------------------------------------------------------------------
1 | from ninja_extra import status
2 | from ninja_extra.exceptions import APIException
3 |
4 |
5 | class MailDoesNotSend(APIException):
6 | default_detail = "Mail does not send"
7 | status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
8 |
9 |
10 | class NotLoggedIn(APIException):
11 | default_detail = "Not logged in"
12 | status_code = status.HTTP_401_UNAUTHORIZED
13 |
14 |
15 | class UnAuthorized(APIException):
16 | message = "UnAuthorized"
17 | status_code = status.HTTP_401_UNAUTHORIZED
18 |
19 |
20 | class TokenExpired(APIException):
21 | default_detail = "Token expired"
22 | status_code = status.HTTP_401_UNAUTHORIZED
23 |
24 |
25 | class TokenDoesNotExist(APIException):
26 | default_detail = "Token does not exist"
27 | status_code = status.HTTP_400_BAD_REQUEST
28 |
29 |
30 | class InvalidToken(APIException):
31 | default_detail = "Invalid token"
32 | status_code = status.HTTP_401_UNAUTHORIZED
33 |
34 |
35 | class UserNotFound(APIException):
36 | default_detail = "User not found"
37 | status_code = status.HTTP_401_UNAUTHORIZED
38 |
39 |
40 | class InvalidCredentials(APIException):
41 | default_detail = "Invalid credentials"
42 | status_code = status.HTTP_401_UNAUTHORIZED
43 |
44 |
45 | class AuthorizationFailed(APIException):
46 | default_detail = "Authorization failed"
47 | status_code = status.HTTP_401_UNAUTHORIZED
48 |
49 |
50 | class RefreshTokenRequired(APIException):
51 | default_detail = "Refresh token required"
52 | status_code = status.HTTP_401_UNAUTHORIZED
53 |
--------------------------------------------------------------------------------
/backend/pyproject.toml:
--------------------------------------------------------------------------------
1 | [tool.ruff]
2 | exclude = [
3 | ".bzr",
4 | ".direnv",
5 | ".eggs",
6 | ".git",
7 | ".git-rewrite",
8 | ".hg",
9 | ".ipynb_checkpoints",
10 | ".mypy_cache",
11 | ".nox",
12 | ".pants.d",
13 | ".pyenv",
14 | ".pytest_cache",
15 | ".pytype",
16 | ".ruff_cache",
17 | ".svn",
18 | ".tox",
19 | ".venv",
20 | ".vscode",
21 | "__pypackages__",
22 | "_build",
23 | "buck-out",
24 | "build",
25 | "dist",
26 | "node_modules",
27 | "site-packages",
28 | "venv",
29 | "*/settings/*.py",
30 | "*/commands/*.py",
31 | ]
32 | line-length = 88
33 | indent-width = 4
34 | target-version = "py311"
35 |
36 | [tool.ruff.lint]
37 | select = [
38 | "E4",
39 | "E7",
40 | "E9",
41 | "F",
42 | "F",
43 | "I",
44 | "W505",
45 | "PT018",
46 | "SIM101",
47 | "SIM114",
48 | "PGH004",
49 | "PLE1142",
50 | "RUF100",
51 | "F404",
52 | "TCH",
53 | ]
54 | ignore = [
55 | "TRY003",
56 | "EM101",
57 | "F401",
58 | "F811",
59 | "TCH004",
60 | ]
61 |
62 | fixable = ["ALL"]
63 | unfixable = []
64 | dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
65 |
66 | [tool.ruff.format]
67 | quote-style = "double"
68 | indent-style = "space"
69 | skip-magic-trailing-comma = false
70 | line-ending = "auto"
71 | docstring-code-format = false
72 | docstring-code-line-length = "dynamic"
73 |
74 | [tool.ruff.lint.per-file-ignores]
75 | "*/admin.py" = ["RUF012"]
76 | "*/migrations/*.py" = ["RUF012"]
77 | "*/tests.py" = ["S101"]
78 | "*/test_*.py" = ["S101"]
79 |
--------------------------------------------------------------------------------
/backend/src/core/adds.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import os
3 |
4 | from django.conf import settings
5 |
6 | from src.core.config import BASE_DIR
7 |
8 | logger = logging.getLogger(__name__)
9 |
10 | os.environ.setdefault(
11 | "DJANGO_SETTINGS_MODULE",
12 | os.getenv("DJANGO_SETTINGS_MODULE", "src.core.settings.dev"),
13 | )
14 |
15 |
16 | class ApiExtra:
17 | VERSION: str
18 |
19 | def __init__(self):
20 | self.VERSION = self._create_version()
21 | self.PREFIX = self._create_prefix()
22 | self._connect_url()
23 |
24 | @staticmethod
25 | def _connect_url():
26 | django_host = settings.DJANGO_HOST
27 | django_port = settings.DJANGO_PORT
28 | if django_host == "0.0.0.0":
29 | django_host = "localhost"
30 | logger.info("http://%s:%s", django_host, django_port)
31 |
32 | def _create_prefix(self):
33 | prefix = "v"
34 | if self.VERSION:
35 | version_parts = self.VERSION.split(".")
36 | for i in version_parts:
37 | if int(i) != 0:
38 | prefix += str(i)
39 | else:
40 | prefix = "v1"
41 | return prefix
42 |
43 | @staticmethod
44 | def _create_version():
45 | try:
46 | with open(BASE_DIR / "VERSION.txt", "r") as version_file:
47 | version = version_file.read()
48 | return version.strip()
49 | except FileNotFoundError:
50 | with open(BASE_DIR / "VERSION.txt", "w") as version_file:
51 | version_file.write("0.1.0")
52 | return "0.1.0"
53 |
--------------------------------------------------------------------------------
/frontend/src/components/nav/Header.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | HomeTwoTone,
3 | EditTwoTone,
4 | CheckCircleTwoTone,
5 | } from "@ant-design/icons";
6 | import { Menu } from "antd";
7 | import { NavLink, Outlet } from "react-router-dom";
8 | import "./Header.css";
9 | import { useState } from "react";
10 | import ButtonLightDark from "components/nav/ButtonLightDark";
11 | import { ThemeProvider } from "context/ThemeProvider";
12 |
13 | const Header = () => {
14 | const [current, setCurrent] = useState("home-key");
15 |
16 | const onClick = (key: string) => {
17 | setCurrent(key);
18 | };
19 | return (
20 |
21 | <>
22 |
47 |
48 | >
49 |
50 | );
51 | };
52 |
53 | export default Header;
54 |
--------------------------------------------------------------------------------
/backend/src/api.py:
--------------------------------------------------------------------------------
1 | from django.conf import settings
2 | from django.conf.urls.static import static
3 | from django.contrib import admin
4 | from django.http import HttpRequest
5 | from django.urls import path
6 | from ninja.constants import NOT_SET
7 | from ninja_extra import NinjaExtraAPI, api_controller, permissions, route
8 | from ninja_extra.exceptions import APIException
9 |
10 | from src.auth.controllers import AuthController
11 | from src.common.controllers import CommonController
12 | from src.common.responses import ORJSONResponse
13 | from src.core.adds import ApiExtra
14 | from src.core.interceptors import AuthBearer
15 | from src.files.controllers import FileController
16 | from src.users.controllers import UsersController
17 |
18 |
19 | @api_controller(auth=NOT_SET, permissions=[], tags=[])
20 | class APIController:
21 | @route.get("/bearer", auth=AuthBearer(), permissions=[permissions.IsAuthenticated])
22 | def bearer(self, request):
23 | if not request.auth:
24 | raise APIException(detail="Invalid token", code=401)
25 | return ORJSONResponse(data=request.auth, status=200)
26 |
27 |
28 | extra = ApiExtra()
29 |
30 | api = NinjaExtraAPI(
31 | version=extra.VERSION,
32 | docs_url="/",
33 | )
34 |
35 | api.register_controllers(
36 | *[
37 | APIController,
38 | CommonController,
39 | UsersController,
40 | AuthController,
41 | FileController,
42 | ]
43 | )
44 |
45 | urlpatterns = [
46 | path("admin/", admin.site.urls),
47 | # path(f"{extra.PREFIX}/", api.urls),
48 | path("", api.urls),
49 | ]
50 |
51 | urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
52 | urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
53 |
--------------------------------------------------------------------------------
/backend/src/users/interfaces/user_interface.py:
--------------------------------------------------------------------------------
1 | from abc import ABC, abstractmethod
2 | from typing import TYPE_CHECKING, Optional
3 | from uuid import UUID
4 |
5 | if TYPE_CHECKING:
6 | from src.users import schemas as user_schemas
7 | from src.users.types import UserType
8 |
9 |
10 | class IUserRepository(ABC):
11 | @abstractmethod
12 | def is_user_exists(
13 | self,
14 | user_id: UUID,
15 | ) -> bool:
16 | pass
17 |
18 | @abstractmethod
19 | def is_superuser_exists(self) -> bool:
20 | pass
21 |
22 | @abstractmethod
23 | def get_user_by_id(
24 | self,
25 | user_id: UUID,
26 | ) -> Optional["UserType"]:
27 | pass
28 |
29 | @abstractmethod
30 | def get_user_by_email(
31 | self,
32 | email: str,
33 | ) -> Optional["UserType"]:
34 | pass
35 |
36 | @abstractmethod
37 | def get_user_by_username(
38 | self,
39 | username: str,
40 | ) -> Optional["UserType"]:
41 | pass
42 |
43 | @abstractmethod
44 | def create_user(
45 | self,
46 | user_create_schema: "user_schemas.UserCreateSchema",
47 | ) -> Optional["UserType"]:
48 | pass
49 |
50 | @abstractmethod
51 | def create_superuser(
52 | self,
53 | super_user_create_schema: "user_schemas.SuperUserCreateSchema",
54 | ) -> Optional["UserType"]:
55 | pass
56 |
57 | @abstractmethod
58 | def update_user(
59 | self,
60 | user_id: UUID,
61 | user_update: "user_schemas.UserUpdateSchema",
62 | ) -> Optional["UserType"]:
63 | pass
64 |
65 | @abstractmethod
66 | def delete_user(
67 | self,
68 | user_id: UUID,
69 | ) -> bool:
70 | pass
71 |
--------------------------------------------------------------------------------
/backend/docker/ninja/Dockerfile:
--------------------------------------------------------------------------------
1 | # base stage
2 | FROM python:3.11.7 AS base
3 |
4 | # build stage
5 | FROM base AS build
6 |
7 | WORKDIR /build
8 |
9 | RUN apt update && \
10 | apt install -y --no-install-recommends -y \
11 | build-essential \
12 | libpq-dev \
13 | && apt purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \
14 | && rm -rf /var/lib/apt/lists/*
15 |
16 | COPY requirements.txt .
17 |
18 | RUN pip wheel --wheel-dir /usr/src/app/wheels \
19 | -r requirements.txt
20 |
21 | # runtime stage
22 | FROM base AS runtime
23 |
24 | ENV BUILD_ENV=dev \
25 | # python:
26 | PYTHONDONTWRITEBYTECODE=1 \
27 | PYTHONUNBUFFERED=1 \
28 | PYTHONDEVMODE=1 \
29 | # pip:
30 | PIP_NO_CACHE_DIR=off \
31 | PIP_DISABLE_PIP_VERSION_CHECK=on \
32 | PIP_DEFAULT_TIMEOUT=100 \
33 | # celery:
34 | C_FORCE_ROOT=1
35 |
36 | WORKDIR /app
37 |
38 | COPY --from=build /usr/src/app/wheels /wheels/
39 |
40 | RUN python -m pip install --no-cache-dir uv
41 |
42 | RUN uv pip install --system --no-cache-dir --no-index --find-links=/wheels/ /wheels/* \
43 | && rm -rf /wheels/
44 |
45 | COPY ./docker/celery/beat.sh /celery/beat.sh
46 | RUN sed -i 's/\r$//g' /celery/beat.sh && chmod +x /celery/beat.sh
47 |
48 | COPY ./docker/celery/worker.sh /celery/worker.sh
49 | RUN sed -i 's/\r$//g' /celery/worker.sh && chmod +x /celery/worker.sh
50 |
51 | COPY ./docker/celery/flower.sh /celery/flower.sh
52 | RUN sed -i 's/\r$//g' /celery/flower.sh && chmod +x /celery/flower.sh
53 |
54 | COPY ./docker/django.sh /django.sh
55 | RUN sed -i 's/\r$//g' /django.sh && chmod +x /django.sh
56 |
57 | COPY ./docker/scripts/*.sh /scripts/
58 | RUN sed -i 's/\r$//g' /scripts/*.sh && chmod +x /scripts/*.sh
59 |
60 | COPY ./docker/entrypoint.sh /entrypoint.sh
61 | RUN sed -i 's/\r$//g' /entrypoint.sh && chmod +x /entrypoint.sh
62 |
63 | COPY . /app
64 |
65 | ENTRYPOINT ["/entrypoint.sh"]
66 |
--------------------------------------------------------------------------------
/backend/src/auth/throttles.py:
--------------------------------------------------------------------------------
1 | import json
2 | import logging
3 | from typing import Optional
4 |
5 | from ninja_extra.throttling import AnonRateThrottle
6 |
7 | logger = logging.getLogger(__name__)
8 |
9 |
10 | class RegisterMailThrottle(AnonRateThrottle):
11 | rate = "10/min"
12 | scope = "minutes"
13 |
14 | def __init__(self):
15 | super().__init__()
16 | self.timed = self.calculate_expiration_time()
17 |
18 | def allow_request(self, request):
19 | allowed = super().allow_request(request)
20 | email = json.loads(request.body)["email"]
21 | key = self.cache.get(email)
22 | self.now = self.timer()
23 | if key:
24 | logger.info("Email %s already registered", json.loads(key)["email"])
25 | allowed = False
26 | else:
27 | self.cache.set(
28 | key=email,
29 | value=json.dumps({"email": email}),
30 | timeout=self.timed,
31 | )
32 | self.history.insert(0, self.now)
33 | return allowed
34 |
35 | def calculate_expiration_time(self):
36 | rate_value, rate_unit = self.rate.split("/")
37 | rate_value = int(rate_value)
38 |
39 | if rate_unit == "min":
40 | expiration_time = rate_value * 60
41 | elif rate_unit == "hour":
42 | expiration_time = rate_value * 3600
43 | else:
44 | expiration_time = rate_value
45 | return expiration_time
46 |
47 | def wait(self) -> Optional[float]:
48 | if self.history:
49 | remaining_duration = self.timed - (self.now - self.history[-1])
50 | else:
51 | remaining_duration = self.timed
52 |
53 | if remaining_duration > 0:
54 | return remaining_duration
55 | return None
56 |
57 |
58 | class RegisterThrottle(AnonRateThrottle):
59 | rate = "2/min"
60 | scope = "minutes"
61 |
--------------------------------------------------------------------------------
/backend/src/users/errors/user_error.py:
--------------------------------------------------------------------------------
1 | from ninja_extra import status
2 | from ninja_extra.exceptions import APIException
3 |
4 |
5 | class UserDoesNotExist(APIException):
6 | default_detail = "User does not exist"
7 | status_code = status.HTTP_404_NOT_FOUND
8 |
9 |
10 | class UserCreateFailed(APIException):
11 | default_detail = "User create failed"
12 | status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
13 |
14 |
15 | class UserUpdateFailed(APIException):
16 | default_detail = "User update failed"
17 | status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
18 |
19 |
20 | class SuperUserCreateFailed(APIException):
21 | default_detail = "Super user create failed"
22 | status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
23 |
24 |
25 | class EmailDoesNotExist(APIException):
26 | default_detail = "Email does not exist"
27 | status_code = status.HTTP_404_NOT_FOUND
28 |
29 |
30 | class UsernameAlreadyExists(APIException):
31 | default_detail = "Username already exists"
32 | status_code = status.HTTP_409_CONFLICT
33 |
34 |
35 | class EmailAlreadyExists(APIException):
36 | default_detail = "Email already exists"
37 | status_code = status.HTTP_409_CONFLICT
38 |
39 |
40 | class WrongPassword(APIException):
41 | default_detail = "Wrong password"
42 | status_code = status.HTTP_401_UNAUTHORIZED
43 |
44 |
45 | class UserNotFound(APIException):
46 | default_detail = "User not found"
47 | status_code = status.HTTP_404_NOT_FOUND
48 |
49 |
50 | class EmailAlreadyInUse(APIException):
51 | default_detail = "You already have this email"
52 | status_code = status.HTTP_400_BAD_REQUEST
53 |
54 |
55 | class WrongOldEmail(APIException):
56 | default_detail = "Wrong old email"
57 | status_code = status.HTTP_400_BAD_REQUEST
58 |
59 |
60 | class EmailUpdateFailed(APIException):
61 | default_detail = "Email update failed"
62 | status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
63 |
--------------------------------------------------------------------------------
/backend/src/data/clients/mail_client.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import os
3 | import smtplib
4 | from typing import Optional
5 |
6 | from django.conf import settings
7 |
8 | from src.data.interfaces.client.abstract_client import IClient
9 |
10 | os.environ.setdefault(
11 | "DJANGO_SETTINGS_MODULE",
12 | os.getenv("DJANGO_SETTINGS_MODULE", "src.core.settings.dev"),
13 | )
14 |
15 |
16 | logger = logging.getLogger(__name__)
17 |
18 |
19 | class MailClient(IClient):
20 | client: smtplib.SMTP = None
21 | port = settings.EMAIL_PORT
22 | host = settings.EMAIL_HOST if settings.EMAIL_HOST != "127.0.0.1" else "localhost"
23 | user = settings.EMAIL_HOST_USER
24 | password = settings.EMAIL_HOST_PASSWORD
25 |
26 | def connect(self, retries=3, **kwargs) -> Optional[smtplib.SMTP]:
27 | attempt = 0
28 | while attempt < retries:
29 | try:
30 | client = smtplib.SMTP(self.host, self.port)
31 | if self.user and self.password:
32 | client.login(self.user, self.password)
33 | self.client = client
34 | logger.info("Successfully connected to the SMTP server")
35 | return self.client
36 | except smtplib.SMTPException as error:
37 | attempt += 1
38 | logger.error(
39 | f"Attempt {attempt} - Failed to connect to SMTP server: {str(error)}"
40 | )
41 | if attempt == retries:
42 | return None
43 | return None
44 |
45 | def disconnect(self, **kwargs) -> None:
46 | if self.client:
47 | try:
48 | self.client.quit()
49 | logger.info("Successfully disconnected from the SMTP server")
50 | except smtplib.SMTPException as error:
51 | logger.error(f"Failed to disconnect from the SMTP server: {str(error)}")
52 | finally:
53 | self.client = None
54 |
--------------------------------------------------------------------------------
/backend/src/users/validations/user_validation.py:
--------------------------------------------------------------------------------
1 | import re
2 |
3 | from django.core import validators
4 | from ninja.errors import HttpError
5 |
6 |
7 | def check_passwords_match(password: str, rewrite_password: str) -> bool:
8 | if password != rewrite_password:
9 | raise HttpError(status_code=400, message="Passwords do not match")
10 | return True
11 |
12 |
13 | def validate_password(password: str) -> str:
14 | if len(password) < 8:
15 | raise HttpError(
16 | status_code=400,
17 | message="Password must be at least 8 characters long",
18 | )
19 | if not any(char.isdigit() for char in password):
20 | raise HttpError(
21 | status_code=400,
22 | message="Password must contain at least one number",
23 | )
24 | if not any(char.isupper() for char in password):
25 | raise HttpError(
26 | status_code=400,
27 | message="Password must contain at least one uppercase letter",
28 | )
29 | if not any(char.islower() for char in password):
30 | raise HttpError(
31 | status_code=400,
32 | message="Password must contain at least one lowercase letter",
33 | )
34 | if not any(char in "!@#$%^&*()_+-=[]{}|;:,.<>/?~" for char in password):
35 | raise HttpError(
36 | status_code=400,
37 | message="Password must contain at least one special character",
38 | )
39 | return password
40 |
41 |
42 | def validate_username(username: str) -> str:
43 | pattern = r"^[a-zA-Z0-9_-]{3,16}$"
44 | if not re.match(pattern, username):
45 | raise HttpError(status_code=400, message="Invalid username")
46 | return username
47 |
48 |
49 | def validate_email(email: str) -> str:
50 | try:
51 | validators.validate_email(email)
52 | except validators.ValidationError:
53 | raise HttpError(status_code=400, message="Invalid email address")
54 | return email
55 |
--------------------------------------------------------------------------------
/backend/src/common/utils.py:
--------------------------------------------------------------------------------
1 | import csv
2 | import inspect
3 | import os
4 | from itertools import chain
5 | from pathlib import Path
6 | from typing import Any, Callable
7 |
8 | from pydantic.main import create_model
9 |
10 | from src.core.config import BASE_DIR, SRC_DIR
11 |
12 |
13 | def get_module_name(file_path: Path, src_dir: Path) -> str:
14 | relative_path = file_path.relative_to(src_dir)
15 |
16 | module_name = str(relative_path).replace("\\", ".")[:-3]
17 |
18 | return module_name
19 |
20 |
21 | def get_full_function_path(
22 | function: Callable[..., Any],
23 | ) -> str:
24 | file_path = Path(inspect.getfile(function))
25 |
26 | function_name = function.__name__
27 |
28 | module_name = get_module_name(file_path=file_path, src_dir=BASE_DIR)
29 |
30 | full_function_path = f"{module_name}.{function_name}"
31 |
32 | return full_function_path
33 |
34 |
35 | def find_file_in_folder(start_folder, file_name):
36 | for item in os.listdir(start_folder):
37 | full_path = os.path.join(start_folder, item)
38 |
39 | if os.path.isdir(full_path):
40 | relative_path = find_file_in_folder(full_path, file_name)
41 | if relative_path:
42 | return os.path.relpath(relative_path, start=start_folder)
43 | elif os.path.isfile(full_path) and item == file_name:
44 | return full_path
45 |
46 | return None
47 |
48 |
49 | def find_file_path_in_project(file_name):
50 | file_path = find_file_in_folder(SRC_DIR, file_name)
51 |
52 | if file_path:
53 | file_path = os.path.join(SRC_DIR.name, str(file_path))
54 | return file_path
55 | else:
56 | return None
57 |
58 |
59 | def pydantic_model(**kwargs: Any) -> Any:
60 | annotations = {name: (type(value), ...) for name, value in kwargs.items()}
61 | config = {"arbitrary_types_allowed": True}
62 | model = create_model("DynamicModel", **annotations, __config__=config)
63 | return model(**kwargs).model_dump()
64 |
--------------------------------------------------------------------------------
/backend/src/data/handlers/redis_handler.py:
--------------------------------------------------------------------------------
1 | import json
2 | from datetime import timedelta
3 | from typing import Any, Optional
4 |
5 | from src.data.interfaces import ICacheHandler
6 | from src.data.storages import RedisStorage
7 |
8 |
9 | class CacheHandler(ICacheHandler):
10 | """
11 | Redis cache handler
12 |
13 | Attributes:
14 | storage (RedisStorage): Redis storage
15 |
16 | Methods:
17 | get_value(key: Any)
18 | set_value(key: Any, value: Any, expire: Optional[int] = None)
19 | delete_value(key: Any)
20 | exists_all_values(key: Any)
21 | delete_all_values()
22 |
23 | Usage:
24 | cache_handler = CacheHandler(pool_storage)
25 | cache_handler.get_value(key="key")
26 | cache_handler.set_value(key="key", value="value")
27 | cache_handler.delete_value(key="key")
28 | cache_handler.exists_all_values(key="key")
29 | cache_handler.delete_all_values()
30 | """
31 |
32 | def __init__(self, pool_storage: RedisStorage, *args, **kwargs):
33 | super().__init__(pool_storage, *args, **kwargs)
34 | self.storage = pool_storage
35 |
36 | def get_value(
37 | self,
38 | key: Any,
39 | ) -> Optional[Any]:
40 | value_json = self.storage.get(key=key)
41 | if value_json:
42 | return json.loads(value_json)
43 | return None
44 |
45 | def set_value(
46 | self,
47 | key: Any,
48 | value: Any,
49 | expire: Optional[int | timedelta] = None,
50 | ) -> None:
51 | value_json = json.dumps(value)
52 | self.storage.set(key=key, value=value_json, expire=expire)
53 |
54 | def delete_value(
55 | self,
56 | key: Any,
57 | ) -> None:
58 | self.storage.delete(key=key)
59 |
60 | def exists_all_values(
61 | self,
62 | key: Any,
63 | ) -> bool:
64 | return self.storage.exists(key=key)
65 |
66 | def delete_all_values(self) -> None:
67 | self.storage.flush()
68 |
--------------------------------------------------------------------------------
/backend/src/data/interfaces/storage/abstract_cloud.py:
--------------------------------------------------------------------------------
1 | from abc import ABC, abstractmethod
2 | from typing import Optional, TypeVar, Union
3 | from uuid import UUID
4 |
5 | from ninja.files import UploadedFile
6 |
7 | ObjectType = TypeVar("ObjectType", bound=Union[UUID, str, int])
8 |
9 |
10 | class ICloudStorage(ABC):
11 | @abstractmethod
12 | def get_full_object_key(
13 | self,
14 | name: ObjectType,
15 | path: str,
16 | ) -> str:
17 | pass
18 |
19 | @abstractmethod
20 | def add_prefix_policy(self, path: list[str] = None):
21 | pass
22 |
23 | @abstractmethod
24 | def get_object_key(
25 | self,
26 | filename: str,
27 | folder: str,
28 | object_key: Optional[ObjectType] = None,
29 | ) -> str:
30 | pass
31 |
32 | @abstractmethod
33 | def upload_file_from_path(
34 | self,
35 | filename: str,
36 | folder: str,
37 | object_key: Optional[ObjectType] = None,
38 | content_type: Optional[str] = None,
39 | ) -> bool:
40 | pass
41 |
42 | @abstractmethod
43 | def upload_file_from_url(
44 | self,
45 | filename: str,
46 | folder: str,
47 | file: UploadedFile,
48 | object_key: Optional[ObjectType] = None,
49 | content_type: Optional[str] = None,
50 | ) -> bool:
51 | pass
52 |
53 | @abstractmethod
54 | def get_file(
55 | self,
56 | filename: str,
57 | folder: Optional[str] = None,
58 | object_key: Optional[ObjectType] = None,
59 | ) -> Optional[str]:
60 | pass
61 |
62 | @abstractmethod
63 | def delete_file(
64 | self,
65 | filename: str,
66 | folder: Optional[str] = None,
67 | object_key: Optional[ObjectType] = None,
68 | content_type: Optional[str] = None,
69 | ) -> bool:
70 | pass
71 |
72 | def is_object_exist(
73 | self,
74 | full_object_key: ObjectType,
75 | ) -> bool:
76 | pass
77 |
--------------------------------------------------------------------------------
/backend/src/orders/validations/shipping_validation.py:
--------------------------------------------------------------------------------
1 | import re
2 | from uuid import UUID
3 |
4 | from geopy.exc import GeocoderTimedOut
5 | from geopy.geocoders import Nominatim
6 | from ninja.errors import HttpError
7 |
8 |
9 | def do_geocode(user_id, address):
10 | geopy = Nominatim(user_agent=str(user_id))
11 | try:
12 | return geopy.geocode(address)
13 | except GeocoderTimedOut:
14 | return do_geocode(user_id, address)
15 |
16 |
17 | def validate_address(
18 | user_id: UUID,
19 | street: str,
20 | district: str,
21 | number: str,
22 | city: str,
23 | postal_code: str,
24 | country: str,
25 | ) -> bool:
26 | postal_pattern = r"^\d{2}-\d{3}$"
27 | if not user_id:
28 | raise HttpError(status_code=400, message="User ID is required")
29 | if not all(
30 | [
31 | street,
32 | number,
33 | city,
34 | postal_code,
35 | country,
36 | ]
37 | ):
38 | raise HttpError(status_code=400, message="All fields are required")
39 | if not re.match(postal_pattern, postal_code):
40 | raise HttpError(status_code=400, message="Invalid postal code")
41 |
42 | post_code_location = do_geocode(user_id, postal_code)
43 | if post_code_location is None:
44 | raise HttpError(status_code=400, message="Postal code is not correct")
45 | post_code_location = post_code_location.address.split(", ")
46 | p_district, p_city = post_code_location[1:3]
47 |
48 | location = do_geocode(user_id, f"{street} {number} {district} {city} {country}")
49 | if location is None:
50 | raise HttpError(status_code=400, message="Street, number, city are not correct")
51 | location = location.address.split(", ")
52 | u_number, u_street = location[0:2]
53 |
54 | if any(value is None for value in (p_district, p_city, u_number, u_street)):
55 | raise HttpError(
56 | status_code=400,
57 | message="Postal code, district, city, province, country are not correct",
58 | )
59 | return True
60 |
--------------------------------------------------------------------------------
/backend/src/core/config.py:
--------------------------------------------------------------------------------
1 | import os
2 | from pathlib import Path
3 | from typing import TYPE_CHECKING, Optional
4 |
5 | from django.conf import settings
6 |
7 | if TYPE_CHECKING:
8 | from src.data.interfaces import ICacheHandler, IPhoneHandler
9 | from src.users.interfaces import IProfileRepository
10 |
11 | os.environ.setdefault(
12 | "DJANGO_SETTINGS_MODULE",
13 | os.getenv("DJANGO_SETTINGS_MODULE", "src.core.settings.dev"),
14 | )
15 |
16 | # PATHS
17 | SRC_DIR = Path(__file__).resolve().parent.parent
18 | BASE_DIR = SRC_DIR.parent
19 | PROJECT_DIR = BASE_DIR.parent
20 |
21 |
22 | def get_storage():
23 | from src.data.clients import AmazonClient, MinioClient
24 | from src.data.storages import AmazonS3Storage, MinioStorage
25 |
26 | if settings.DEBUG_ON:
27 | aws_client = AmazonClient(
28 | access_key=settings.AWS_ACCESS_KEY,
29 | secret_key=settings.AWS_SECRET_KEY,
30 | region_name=settings.AWS_REGION_NAME,
31 | )
32 | storage = AmazonS3Storage(client=aws_client)
33 | else:
34 | minio_client = MinioClient(
35 | host=settings.MINIO_HOST,
36 | port=settings.MINIO_PORT,
37 | access_key=settings.MINIO_ROOT_USER,
38 | secret_key=settings.MINIO_ROOT_PASSWORD,
39 | secure=False,
40 | )
41 | storage = MinioStorage(client=minio_client)
42 |
43 | return storage
44 |
45 |
46 | def get_phone_handler(
47 | cache: Optional["ICacheHandler"] = None,
48 | repository: Optional["IProfileRepository"] = None,
49 | ) -> "IPhoneHandler":
50 | from src.data.clients import VonageClient
51 | from src.data.handlers import FakePhoneHandler, VonagePhoneHandler
52 |
53 | if settings.DEBUG_ON:
54 | client = VonageClient()
55 | return VonagePhoneHandler(
56 | client=client,
57 | cache=cache,
58 | repository=repository,
59 | )
60 | else:
61 | return FakePhoneHandler(
62 | client=None,
63 | cache=cache,
64 | repository=repository,
65 | )
66 |
--------------------------------------------------------------------------------
/backend/src/orders/models/order_model.py:
--------------------------------------------------------------------------------
1 | from django.db import models
2 |
3 | from src.common.models import CreatedUpdatedDateModel
4 | from src.orders.enums import OrderStatus, PaymentMethod
5 | from src.users.models import User
6 |
7 | # Create your models here.
8 |
9 |
10 | class Order(CreatedUpdatedDateModel):
11 | id = models.AutoField(
12 | primary_key=True,
13 | editable=False,
14 | )
15 | shipping_price = models.DecimalField(
16 | max_digits=10,
17 | decimal_places=2,
18 | null=True,
19 | blank=True,
20 | )
21 | tax_price = models.DecimalField(
22 | max_digits=10,
23 | decimal_places=2,
24 | null=True,
25 | blank=True,
26 | )
27 | total_price = models.DecimalField(
28 | max_digits=10,
29 | decimal_places=2,
30 | null=True,
31 | blank=True,
32 | )
33 | return_relation = models.TextField(
34 | null=True,
35 | blank=True,
36 | )
37 | return_is_approved = models.BooleanField(
38 | default=False,
39 | null=True,
40 | blank=True,
41 | )
42 | return_timeleft = models.IntegerField(
43 | default=14,
44 | null=True,
45 | blank=True,
46 | )
47 |
48 | delivered_at = models.DateTimeField(
49 | null=True,
50 | blank=True,
51 | )
52 | paid_at = models.DateTimeField(
53 | null=True,
54 | blank=True,
55 | )
56 | return_at = models.DateTimeField(
57 | null=True,
58 | blank=True,
59 | )
60 | payment_method = models.CharField(
61 | max_length=100,
62 | null=True,
63 | blank=True,
64 | choices=[(p.name, p.value) for p in PaymentMethod],
65 | )
66 | status = models.CharField(
67 | max_length=100,
68 | choices=[(s.name, s.value) for s in OrderStatus],
69 | default=OrderStatus.CREATED.value,
70 | )
71 | # relationships
72 | user = models.ForeignKey(
73 | User,
74 | on_delete=models.SET_NULL,
75 | related_name="orders",
76 | null=True,
77 | )
78 |
--------------------------------------------------------------------------------
/backend/src/files/controllers.py:
--------------------------------------------------------------------------------
1 | from django.http import HttpRequest
2 | from ninja import UploadedFile
3 | from ninja_extra import api_controller, permissions, route
4 |
5 | from src.common.responses import ORJSONResponse
6 | from src.core.config import get_storage
7 | from src.core.interceptors import AuthBearer
8 | from src.data.handlers import (
9 | AvatarFileHandler,
10 | )
11 | from src.files.services import FileService
12 |
13 |
14 | @api_controller(
15 | prefix_or_class="/files",
16 | auth=AuthBearer(),
17 | permissions=[permissions.IsAuthenticated],
18 | tags=["files"],
19 | )
20 | class FileController:
21 | avatar_handler = AvatarFileHandler(storage=get_storage())
22 |
23 | service = FileService(
24 | avatar_handler,
25 | )
26 |
27 | @route.get("/avatar/get")
28 | def get_avatar(self, request: HttpRequest):
29 | return ORJSONResponse(
30 | self.service.get_avatar(
31 | request.user.pk,
32 | ).model_dump(),
33 | status=200,
34 | )
35 |
36 | @route.post("/avatar/upload")
37 | def upload_avatar(
38 | self,
39 | file: UploadedFile,
40 | request: HttpRequest,
41 | ):
42 | return ORJSONResponse(
43 | data=self.service.upload_avatar(
44 | file=file,
45 | user_id=request.user.pk,
46 | ).model_dump(),
47 | status=201,
48 | )
49 |
50 | @route.post("/avatar/update")
51 | def update_avatar(
52 | self,
53 | file: UploadedFile,
54 | request: HttpRequest,
55 | ):
56 | return ORJSONResponse(
57 | data=self.service.update_avatar(
58 | file=file,
59 | user_id=request.user.pk,
60 | ).model_dump(),
61 | status=200,
62 | )
63 |
64 | @route.delete("/avatar/delete")
65 | def delete_avatar(self, request: HttpRequest):
66 | return ORJSONResponse(
67 | data=self.service.delete_avatar(
68 | user_id=request.user.pk,
69 | ).model_dump(),
70 | status=200,
71 | )
72 |
--------------------------------------------------------------------------------
/backend/src/users/interfaces/profile_interface.py:
--------------------------------------------------------------------------------
1 | from abc import ABC, abstractmethod
2 | from typing import TYPE_CHECKING, Optional
3 | from uuid import UUID
4 |
5 | if TYPE_CHECKING:
6 | from src.users.schemas import (
7 | PhoneCodeSchema,
8 | PhoneNumberSchema,
9 | ProfileCreateSchema,
10 | ProfileUpdateSchema,
11 | )
12 | from src.users.types import ProfileType, UserType
13 |
14 |
15 | class IProfileRepository(ABC):
16 | @abstractmethod
17 | def is_profile_exists(self, user_id: UUID) -> bool:
18 | pass
19 |
20 | @abstractmethod
21 | def get_profile_by_user_id(
22 | self,
23 | user_id: UUID,
24 | ) -> Optional["ProfileType"]:
25 | pass
26 |
27 | @abstractmethod
28 | def get_profile_by_id(
29 | self,
30 | profile_id: UUID,
31 | ) -> Optional["ProfileType"]:
32 | pass
33 |
34 | @abstractmethod
35 | def create_profile(
36 | self,
37 | profile_create: "ProfileCreateSchema",
38 | user_id: UUID,
39 | ) -> Optional["ProfileType"]:
40 | pass
41 |
42 | @abstractmethod
43 | def update_profile(
44 | self,
45 | user_id: UUID,
46 | profile_update: "ProfileUpdateSchema",
47 | ) -> bool:
48 | pass
49 |
50 | @abstractmethod
51 | def delete_profile(
52 | self,
53 | user_id: UUID,
54 | ) -> bool:
55 | pass
56 |
57 | @abstractmethod
58 | def is_phone_exists(
59 | self,
60 | user_id: UUID,
61 | phone_number: str,
62 | ) -> bool:
63 | pass
64 |
65 | @abstractmethod
66 | def get_phone(
67 | self,
68 | user_id: UUID,
69 | ) -> Optional[str]:
70 | pass
71 |
72 | @abstractmethod
73 | def create_phone(
74 | self,
75 | user_id: UUID,
76 | phone_number: str,
77 | ) -> bool:
78 | pass
79 |
80 | @abstractmethod
81 | def update_phone(
82 | self,
83 | user_id: UUID,
84 | phone_number: str,
85 | ) -> bool:
86 | pass
87 |
88 | @abstractmethod
89 | def delete_phone(
90 | self,
91 | user_id: UUID,
92 | ) -> bool:
93 | pass
94 |
--------------------------------------------------------------------------------
/backend/src/common/mixins.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | import json
3 | from typing import Any, Optional
4 |
5 | from django.db import models
6 |
7 |
8 | class ModelToDictToJsonMixin:
9 | def to_dict(
10 | self,
11 | include: Optional[list[str]] = None,
12 | exclude: Optional[list[str]] = None,
13 | ) -> dict[str, Any]:
14 | data: dict[str, Any] = {}
15 |
16 | for field in self._meta.fields: # noqa:
17 | value = getattr(self, field.name)
18 |
19 | if isinstance(field, models.UUIDField):
20 | data[field.name] = str(value) if value else None
21 | elif isinstance(field, (models.DateField, models.DateTimeField)):
22 | if isinstance(value, (datetime.date, datetime.datetime)):
23 | data[field.name] = value.isoformat() if value else None
24 | else:
25 | data[field.name] = value
26 | elif isinstance(field, models.ForeignKey or models.OneToOneField):
27 | data[field.name] = str(value.id) if value else None
28 | elif isinstance(field, models.URLField):
29 | data[field.name] = value.url if value else None
30 | elif isinstance(field, models.ManyToManyField):
31 | data[field.name] = (
32 | [str(item.id) for item in value.all()] if value.exists() else []
33 | )
34 | else:
35 | data[field.name] = value
36 |
37 | if exclude:
38 | data = {k: v for k, v in data.items() if k not in exclude}
39 |
40 | if include:
41 | data = {k: v for k, v in data.items() if k in include}
42 |
43 | return data
44 |
45 | def to_json(
46 | self,
47 | exclude: Optional[list[str]] = None,
48 | include: Optional[list[str]] = None,
49 | ) -> str:
50 | if hasattr(self, "to_dict"):
51 | try:
52 | return json.dumps(self.to_dict(exclude=exclude, include=include))
53 | except TypeError as error:
54 | raise ValueError(f"Error serializing to JSON: {error}")
55 | else:
56 | raise AttributeError("The object does not have a 'to_dict' method.")
57 |
--------------------------------------------------------------------------------
/backend/src/core/interceptors/auth_interceptors.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from typing import TYPE_CHECKING, Any, Optional
3 |
4 | from django.contrib.auth import get_user_model
5 | from django.http import HttpRequest
6 | from ninja.security import HttpBearer
7 | from ninja_extra.exceptions import APIException
8 |
9 | from src.auth.errors import InvalidToken, UserNotFound
10 | from src.auth.utils import decode_jwt_token
11 | from src.common.utils import pydantic_model
12 | from src.users.repositories import UserRepository
13 |
14 | if TYPE_CHECKING:
15 | from src.users.types import UserType
16 |
17 | logger = logging.getLogger(__name__)
18 |
19 |
20 | def get_user_id(request: HttpRequest) -> Optional[dict]:
21 | user_id = request.user.id if request.user else None
22 | payload = get_token_payload(request=request)
23 |
24 | if payload:
25 | user_id = payload.get("user_id")
26 |
27 | return pydantic_model(user_id=user_id) if user_id else None
28 |
29 |
30 | def get_token_payload(request: HttpRequest) -> Optional[dict]:
31 | header = "Authorization"
32 | headers = request.headers
33 | auth_value = headers.get(header)
34 | if not auth_value:
35 | return None
36 | parts = auth_value.split(" ")
37 | token = " ".join(parts[1:])
38 | try:
39 | decode_token = decode_jwt_token(token)
40 | if decode_token:
41 | return decode_token
42 | except APIException:
43 | raise InvalidToken
44 |
45 |
46 | class AuthBearer(HttpBearer):
47 | user_repository = UserRepository()
48 |
49 | def get_user(self, request: HttpRequest, user_id: Any) -> None:
50 | try:
51 | user = self.user_repository.get_user_by_id(user_id=user_id)
52 | request.user = user
53 | except APIException:
54 | raise UserNotFound
55 |
56 | def authenticate(
57 | self, request: HttpRequest, token: Optional[str]
58 | ) -> Optional[dict]:
59 | if not token:
60 | raise InvalidToken
61 | try:
62 | decode_token = decode_jwt_token(token)
63 | if not decode_token:
64 | raise InvalidToken
65 |
66 | user_id = decode_token.get("user_id")
67 | self.get_user(request=request, user_id=user_id)
68 | return decode_token
69 | except APIException:
70 | raise InvalidToken
71 |
--------------------------------------------------------------------------------
/backend/src/users/validations/profile_validation.py:
--------------------------------------------------------------------------------
1 | from datetime import date, datetime
2 |
3 | import phonenumbers
4 | from ninja_extra.exceptions import APIException
5 |
6 |
7 | def validate_phone(country_code: str, number: str) -> str:
8 | if not number:
9 | raise APIException(
10 | detail="Phone number is required",
11 | code=400,
12 | )
13 |
14 | if not country_code:
15 | raise APIException(
16 | detail="Country code required",
17 | code=400,
18 | )
19 |
20 | if len(number) < 9 or isinstance(int(number), int) is False:
21 | raise APIException(
22 | detail="Phone number must be 9 digits long",
23 | code=400,
24 | )
25 | phone = country_code + number
26 | parsed_number = phonenumbers.parse(phone, None)
27 | if not phonenumbers.is_valid_number(parsed_number):
28 | raise APIException(
29 | detail=f"Invalid phone number {parsed_number.national_number}",
30 | code=400,
31 | )
32 | return country_code + str(parsed_number.national_number)
33 |
34 |
35 | def validate_code(code: str) -> str:
36 | if not code:
37 | raise APIException(
38 | detail="Code is required",
39 | code=400,
40 | )
41 |
42 | if len(code) != 4:
43 | raise APIException(
44 | detail="Code must be 4 digits long",
45 | code=400,
46 | )
47 | return code
48 |
49 |
50 | def validate_birth_date(birth_date: str) -> str:
51 | today_date = datetime.now().date()
52 |
53 | try:
54 | birth_date = datetime.strptime(birth_date, "%Y-%m-%d").date()
55 | except APIException:
56 | raise APIException(
57 | detail="Invalid birth date format. Valid format is YYYY-MM-DD.",
58 | code=400,
59 | )
60 | if today_date.year - birth_date.year > 200:
61 | raise APIException(
62 | detail="User must be less than 200 years old",
63 | code=400,
64 | )
65 |
66 | if birth_date > today_date:
67 | raise APIException(
68 | detail="Birth date cannot be in the future",
69 | code=400,
70 | )
71 |
72 | if today_date.year - birth_date.year < 18:
73 | raise APIException(
74 | detail="User must be at least 18 years old",
75 | code=400,
76 | )
77 |
78 | return birth_date.strftime("%Y-%m-%d")
79 |
--------------------------------------------------------------------------------
/backend/src/common/errors/exceptions.py:
--------------------------------------------------------------------------------
1 | from typing import Any, Optional
2 |
3 | from django.http.request import HttpRequest
4 | from django.http.response import HttpResponse
5 | from ninja.renderers import BaseRenderer
6 |
7 | from src.common.errors.constants import StatusCodes
8 |
9 |
10 | class HTTPException:
11 | def __init__(
12 | self,
13 | request: HttpRequest,
14 | data: Any,
15 | *,
16 | message: Optional[str] = None,
17 | status: Optional[int] = None,
18 | renderer: Optional[BaseRenderer] = None,
19 | temporal_response: Optional[HttpResponse] = None,
20 | ) -> None:
21 | self.renderer = renderer
22 | self.create_response(
23 | request,
24 | data,
25 | message=message,
26 | status=status,
27 | temporal_response=temporal_response,
28 | )
29 |
30 | def get_content_type(self) -> str:
31 | return f"{self.renderer.media_type}; charset={self.renderer.charset}"
32 |
33 | def create_response(
34 | self,
35 | request: HttpRequest,
36 | data: Any,
37 | *,
38 | message: Optional[str] = None,
39 | status: Optional[int] = None,
40 | temporal_response: Optional[HttpResponse] = None,
41 | ) -> HttpResponse:
42 | if temporal_response:
43 | status = temporal_response.status_code
44 | assert status
45 |
46 | if message:
47 | data["message"] = message
48 | content = self.renderer.render(request, data, response_status=status)
49 |
50 | if temporal_response:
51 | response = temporal_response
52 | response.content = content
53 | else:
54 | response = HttpResponse(
55 | content, status=status, content_type=self.get_content_type()
56 | )
57 |
58 | return response
59 |
60 |
61 | class BasicHTTPException(HTTPException):
62 | MESSAGE: Optional[str] = None
63 | STATUS: Optional[int] = None
64 |
65 | def __init__(self, request: HttpRequest, data: Any, *args, **kwargs) -> None:
66 | super().__init__(
67 | request=request,
68 | data=data,
69 | message=self.MESSAGE,
70 | status=self.STATUS,
71 | *args,
72 | **kwargs,
73 | )
74 |
75 |
76 | class ServerError(BasicHTTPException):
77 | MESSAGE = "Internal server error"
78 | STATUS = StatusCodes.SERVER_ERROR
79 |
--------------------------------------------------------------------------------
/backend/src/users/old_controllers/profile_controller.py:
--------------------------------------------------------------------------------
1 | # import logging
2 | #
3 | # from django.http import HttpRequest, JsonResponse
4 | # from ninja.constants import NOT_SET
5 | # from ninja_extra import api_controller, route
6 | # from ninja_extra.permissions.common import IsAuthenticated
7 | #
8 | # from src.core.handler import get_phone_handler
9 | # from src.core.interceptors import AuthBearer
10 | # from src.core.storage import get_storage
11 | # from src.data.handlers import AvatarFileHandler, CacheHandler, EventHandler
12 | # from src.data.managers import EventManager
13 | # from src.data.storages import RedisStorage
14 | # from src.users.repositories import ProfileRepository
15 | # from src.users.schemas import CreatePhoneSchema, RegisterPhoneSchema
16 | # from src.users.services import ProfileService
17 | #
18 | # logger = logging.getLogger(__name__)
19 | #
20 | #
21 | # @api_controller(
22 | # prefix_or_class="/profiles",
23 | # auth=AuthBearer(),
24 | # permissions=[],
25 | # tags=["profiles"],
26 | # )
27 | # class ProfileController:
28 | # profile_repository = ProfileRepository()
29 | # event_handler = EventHandler(manager=EventManager())
30 | # avatar_handler = AvatarFileHandler(storage=get_storage())
31 | # cache_handler = CacheHandler(pool_storage=RedisStorage())
32 | #
33 | # service = ProfileService(
34 | # repository=profile_repository,
35 | # event_handler=event_handler,
36 | # avatar_handler=avatar_handler,
37 | # cache_handler=cache_handler,
38 | # phone_handler=get_phone_handler(
39 | # cache=cache_handler,
40 | # repository=profile_repository,
41 | # ),
42 | # )
43 | #
44 | # @route.post(
45 | # "/phone/register",
46 | # auth=AuthBearer(),
47 | # permissions=[IsAuthenticated],
48 | # )
49 | # def register_phone(
50 | # self,
51 | # phone: RegisterPhoneSchema,
52 | # request: HttpRequest,
53 | # ):
54 | # return self.service.register_phone(
55 | # user_id=request.user.pk,
56 | # phone=phone,
57 | # )
58 | #
59 | # @route.post(
60 | # "/phone/create",
61 | # auth=AuthBearer(),
62 | # permissions=[IsAuthenticated],
63 | # )
64 | # def create_phone(
65 | # self,
66 | # phone: CreatePhoneSchema,
67 | # request: HttpRequest,
68 | # ):
69 | # return self.service.create_phone(
70 | # user_id=request.user.pk,
71 | # phone=phone,
72 | # )
73 |
--------------------------------------------------------------------------------
/frontend/src/dev/README.md:
--------------------------------------------------------------------------------
1 | This directory contains utility files which enable some visual features of the
2 | [React Buddy](https://plugins.jetbrains.com/plugin/17467-react-buddy/) plugin.
3 | Files in the directory should be committed to source control.
4 |
5 | React Buddy palettes describe reusable components and building blocks. `React Palette` tool window becomes available
6 | when an editor with React components is active. You can drag and drop items from the tool window to the code editor or
7 | JSX Outline. Alternatively, you can insert components from the palette using code generation
8 | action (`alt+insert` / `⌘ N`).
9 |
10 | Add components to the palette using `Add to React Palette` intention or via palette editor (look for the corresponding
11 | link in `palette.tsx`). There are some ready-to-use palettes for popular React libraries which are published as npm
12 | packages and can be added as a dependency:
13 |
14 | ```jsx
15 | import AntdPalette from "@react-buddy/palette-antd";
16 | import ReactIntlPalette from "@react-buddy/palette-react-intl";
17 |
18 | export const PaletteTree = () => (
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 | Card content
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 | );
38 | ```
39 |
40 | React Buddy explicitly registers any previewed component in the `previews.tsx` file so that you can specify required
41 | props.
42 |
43 | ```jsx
44 |
45 |
46 |
47 | ```
48 |
49 | You can add some global initialization logic for the preview mode in `useInitital.ts`,
50 | e.g. implicitly obtain user session:
51 |
52 | ```typescript
53 | export const useInitial: () => InitialHookStatus = () => {
54 | const [loading, setLoading] = useState(false);
55 | const [error, setError] = useState(false);
56 |
57 | useEffect(() => {
58 | setLoading(true);
59 | async function login() {
60 | const response = await loginRequest(DEV_LOGIN, DEV_PASSWORD);
61 | if (response?.status !== 200) {
62 | setError(true);
63 | }
64 | setLoading(false);
65 | }
66 | login();
67 | }, []);
68 | return { loading, error };
69 | };
70 | ```
71 |
--------------------------------------------------------------------------------
/backend/src/data/clients/minio_client.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import os
3 | from typing import Optional
4 |
5 | from django.conf import settings
6 | from minio import Minio, S3Error
7 |
8 | from src.data.interfaces.client.abstract_client import IClient
9 |
10 | os.environ.setdefault(
11 | "DJANGO_SETTINGS_MODULE",
12 | os.getenv("DJANGO_SETTINGS_MODULE", "src.core.settings.dev"),
13 | )
14 |
15 |
16 | logger = logging.getLogger(__name__)
17 |
18 |
19 | class MinioClient(IClient):
20 | minio: Optional[Minio] = None
21 |
22 | def __init__(
23 | self,
24 | host: str,
25 | port: str,
26 | access_key: str,
27 | secret_key: str,
28 | secure: bool = False,
29 | region_name: str = "eu-central-1",
30 | *args,
31 | **kwargs,
32 | ) -> None:
33 | self.minio = self.connect(
34 | host=host,
35 | port=port,
36 | access_key=access_key,
37 | secret_key=secret_key,
38 | secure=secure,
39 | *args,
40 | **kwargs,
41 | )
42 | self._raise_init()
43 | self.bucket_name = settings.BUCKET_NAME
44 | self.static = settings.STATIC_PATH
45 | self.region = region_name
46 | self._load_basic_buckets()
47 |
48 | @staticmethod
49 | def _raise_init():
50 | if not settings.BUCKET_NAME:
51 | raise ValueError("MINIO: BUCKET_NAME is not set")
52 | if not settings.STATIC_PATH:
53 | raise ValueError("MINIO: STATIC_PATH is not set")
54 |
55 | def _load_basic_buckets(self) -> None:
56 | if not self.minio.bucket_exists(bucket_name=self.bucket_name):
57 | self.minio.make_bucket(bucket_name=self.bucket_name)
58 |
59 | def connect(
60 | self,
61 | host: str,
62 | port: str,
63 | access_key: str,
64 | secret_key: str,
65 | secure: bool = False,
66 | *args,
67 | **kwargs,
68 | ) -> Minio:
69 | try:
70 | if not self.minio:
71 | endpoint = f"{host}:{port}"
72 | minio = Minio(
73 | endpoint=endpoint,
74 | access_key=access_key,
75 | secret_key=secret_key,
76 | secure=secure,
77 | )
78 | self.minio = minio
79 | return self.minio
80 | except S3Error as error:
81 | logger.error(error)
82 |
83 | except Exception as error:
84 | logger.error(error)
85 |
86 | def disconnect(self, **kwargs) -> None:
87 | self.minio = None
88 |
--------------------------------------------------------------------------------
/backend/src/data/clients/amazon_client.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import os
3 | from typing import Optional
4 |
5 | import boto3
6 | from botocore.client import BaseClient, ClientError
7 | from django.conf import settings
8 |
9 | from src.data.interfaces.client.abstract_client import IClient
10 |
11 | os.environ.setdefault(
12 | "DJANGO_SETTINGS_MODULE",
13 | os.getenv("DJANGO_SETTINGS_MODULE", "src.core.settings.dev"),
14 | )
15 |
16 | logger = logging.getLogger(__name__)
17 |
18 |
19 | class AmazonClient(IClient):
20 | amazon_s3: Optional[BaseClient] = None
21 | amazon_s3_resource = None
22 |
23 | def __init__(
24 | self,
25 | access_key: str,
26 | secret_key: str,
27 | region_name: str = "eu-central-1",
28 | *args,
29 | **kwargs,
30 | ) -> None:
31 | self.amazon_s3: Optional[BaseClient] = self.connect(
32 | access_key=access_key,
33 | secret_key=secret_key,
34 | region_name=region_name,
35 | *args,
36 | **kwargs,
37 | )
38 | self._raise_init()
39 | self.static = settings.STATIC_PATH
40 | self.bucket_name = settings.BUCKET_NAME
41 | self._load_basic_bucket()
42 |
43 | @staticmethod
44 | def _raise_init():
45 | if not settings.BUCKET_NAME:
46 | raise ValueError("AMAZON: BUCKET_NAME is not set")
47 | if not settings.STATIC_PATH:
48 | raise ValueError("AMAZON: STATIC_PATH is not set")
49 |
50 | def _load_basic_bucket(self) -> None:
51 | if not self.amazon_s3.list_buckets()["Buckets"]:
52 | self.amazon_s3.create_bucket(
53 | Bucket=self.bucket_name,
54 | ACL="bucket-owner-full-control",
55 | CreateBucketConfiguration={
56 | "LocationConstraint": settings.AWS_REGION_NAME,
57 | },
58 | )
59 |
60 | def connect(
61 | self,
62 | access_key: str,
63 | secret_key: str,
64 | region_name: str,
65 | *args,
66 | **kwargs,
67 | ) -> BaseClient:
68 | try:
69 | session = boto3.session.Session(
70 | aws_access_key_id=access_key,
71 | aws_secret_access_key=secret_key,
72 | region_name=region_name,
73 | )
74 |
75 | self.amazon_s3 = session.client("s3")
76 | self.amazon_s3_resource = session.resource("s3")
77 | return self.amazon_s3
78 |
79 | except ClientError as error:
80 | logger.error(error)
81 |
82 | def disconnect(self, **kwargs) -> None:
83 | self.amazon_s3.close()
84 |
--------------------------------------------------------------------------------
/backend/src/products/models.py:
--------------------------------------------------------------------------------
1 | from django.db import models
2 |
3 | from src.categories.models import Category
4 | from src.common.models import CreatedUpdatedDateModel
5 | from src.events.models import Event
6 | from src.users.models import User
7 |
8 | # Create your models here.
9 |
10 |
11 | class Product(CreatedUpdatedDateModel):
12 | id = models.AutoField(
13 | primary_key=True,
14 | editable=False,
15 | )
16 | name = models.CharField(
17 | max_length=100,
18 | null=True,
19 | blank=True,
20 | )
21 | image = models.URLField()
22 | brand = models.CharField(
23 | max_length=100,
24 | null=True,
25 | blank=True,
26 | )
27 | description = models.TextField(
28 | null=True,
29 | blank=True,
30 | )
31 | price = models.DecimalField(
32 | max_digits=10,
33 | decimal_places=2,
34 | null=True,
35 | blank=True,
36 | )
37 | event_price = models.DecimalField(
38 | max_digits=10,
39 | decimal_places=2,
40 | null=True,
41 | blank=True,
42 | )
43 | rating = (
44 | models.DecimalField(
45 | max_digits=7,
46 | decimal_places=2,
47 | null=True,
48 | blank=True,
49 | ),
50 | )
51 | num_reviews = models.IntegerField(
52 | default=0,
53 | null=True,
54 | blank=True,
55 | )
56 | count_in_stock = models.IntegerField(
57 | default=0,
58 | null=True,
59 | blank=True,
60 | )
61 |
62 | # relationships
63 | category = models.ForeignKey(
64 | Category,
65 | on_delete=models.SET_NULL,
66 | null=True,
67 | related_name="products",
68 | )
69 | events = models.ManyToManyField(
70 | Event,
71 | through="ProductEvent",
72 | through_fields=("product", "event"),
73 | )
74 |
75 | def __str__(self):
76 | return self.name
77 |
78 |
79 | class ProductEvent(models.Model):
80 | id = models.AutoField(
81 | primary_key=True,
82 | editable=False,
83 | )
84 | event = models.ForeignKey(
85 | Event,
86 | on_delete=models.SET_NULL,
87 | null=True,
88 | blank=True,
89 | )
90 | product = models.ForeignKey(
91 | Product,
92 | on_delete=models.SET_NULL,
93 | null=True,
94 | blank=True,
95 | )
96 | event_product_price = models.DecimalField(
97 | max_digits=10,
98 | decimal_places=2,
99 | null=True,
100 | blank=True,
101 | )
102 |
--------------------------------------------------------------------------------
/backend/src/users/tasks.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from typing import TYPE_CHECKING, Optional
3 | from uuid import UUID
4 |
5 | from src.core.celery import celery
6 | from src.users.repositories import ProfileRepository, UserRepository
7 | from src.users.schemas import ProfileCreateSchema, UserCreateSchema
8 |
9 | if TYPE_CHECKING:
10 | from src.users.types import UserType
11 |
12 | logger = logging.getLogger(__name__)
13 |
14 |
15 | @celery.task(queue="tasks")
16 | def create_user_task(user_create: dict) -> Optional[str]:
17 | logger.info("Received user creation request: %s", user_create)
18 | repository = UserRepository()
19 |
20 | try:
21 | user = repository.create_user(
22 | user_create_schema=UserCreateSchema(**user_create)
23 | )
24 | if user:
25 | logger.info("create_user_task: %s", user.to_dict(include=["id", "email"]))
26 | logger.info(
27 | "User created successfully with email [blue]%s[/]",
28 | user.email,
29 | extra={"markup": True},
30 | )
31 | return str(user.id)
32 | else:
33 | logger.warning("User creation failed, repository returned None.")
34 | except Exception as e:
35 | logger.error("An exception occurred: %s", str(e))
36 |
37 | logger.info(
38 | "User creation failed for email [blue]%s[/]",
39 | user_create["email"],
40 | extra={"markup": True},
41 | )
42 | return None
43 |
44 |
45 | @celery.task(queue="tasks")
46 | def delete_user_task(user_id: str) -> bool:
47 | repository = UserRepository()
48 | return repository.delete_user(user_id=UUID(user_id))
49 |
50 |
51 | @celery.task(queue="tasks")
52 | def delete_profile_task(user_id: str) -> bool:
53 | repository = ProfileRepository()
54 | return repository.delete_profile(user_id=UUID(user_id))
55 |
56 |
57 | @celery.task(queue="tasks")
58 | def create_profile_task(profile_create_data: dict, user_id: str) -> Optional[str]:
59 | repository = ProfileRepository()
60 | profile = repository.create_profile(
61 | profile_create=ProfileCreateSchema(**profile_create_data),
62 | user_id=UUID(user_id),
63 | )
64 | if profile:
65 | logger.info(
66 | "create_profile_task: %s", profile.to_dict(include=["id", "birth_date"])
67 | )
68 | logger.info(
69 | "Profile created successfully for user [blue]%s[/]",
70 | user_id,
71 | extra={"markup": True},
72 | )
73 | return str(profile.id)
74 | logger.info(
75 | "Profile creation failed for user [blue]%s[/]",
76 | user_id,
77 | extra={"markup": True},
78 | )
79 | return None
80 |
--------------------------------------------------------------------------------
/backend/src/data/managers/task_manager.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from datetime import timedelta
3 | from functools import wraps
4 | from typing import Any, Callable, Optional, Union
5 |
6 | from celery.app.base import Celery
7 | from celery.canvas import signature
8 | from celery.schedules import crontab, schedule, solar
9 |
10 | from src.core.celery import celery as celery_app
11 |
12 | logger = logging.getLogger(__name__)
13 |
14 |
15 | class TaskManager:
16 | def __init__(self, client: Celery = celery_app, queue: Optional[str] = None):
17 | self.celery = client
18 | self.queue = queue
19 |
20 | def add_to_beat_schedule(self, key: str, value: Any):
21 | self.celery.conf.beat_schedule[key] = value
22 |
23 | def task(
24 | self, function: Callable[..., Any], queue: Optional[str] = None
25 | ) -> Callable[..., Any]:
26 | task_queue = queue or self.queue
27 |
28 | @self.celery.task(name=function.__name__, queue=task_queue)
29 | @wraps(function)
30 | def wrapper(*args, **kwargs):
31 | return function(*args, **kwargs)
32 |
33 | def get_result(*a, **k):
34 | result = wrapper.apply_async(*a, **k)
35 | task_result = result.get()
36 | return task_result
37 |
38 | def get_result_countdown(*a, countdown: Union[timedelta, int] = 0, **k):
39 | if isinstance(countdown, timedelta):
40 | countdown = countdown.total_seconds()
41 | result = wrapper.apply_async(args=a, kwargs=k, countdown=countdown)
42 | task_result = result.get()
43 | return task_result
44 |
45 | wrapper.get_result = get_result
46 | wrapper.get_result_countdown = get_result_countdown
47 | return wrapper
48 |
49 | def add_task(self, queue: Optional[str] = None) -> Callable[..., Any]:
50 | def decorator(function: Callable[..., Any]) -> Callable[..., Any]:
51 | return self.task(function, queue)
52 |
53 | return decorator
54 |
55 | def add_periodic_task(
56 | self,
57 | name: str,
58 | schedule_interval: Union[timedelta, crontab, solar, schedule],
59 | queue: Optional[str] = None,
60 | ) -> Callable[..., Any]:
61 | if not queue:
62 | queue = self.queue
63 |
64 | def decorator(function: Callable[..., Any]) -> Callable[..., Any]:
65 | sig = signature(function.__name__)
66 | self.celery.add_periodic_task(
67 | schedule_interval,
68 | sig,
69 | name=name,
70 | options={"queue": queue},
71 | )
72 |
73 | @wraps(function)
74 | def wrapper(*args, **kwargs):
75 | return function(*args, **kwargs)
76 |
77 | def run(*a, **k):
78 | wrapper(*a, **k)
79 |
80 | wrapper.run = run
81 | return wrapper
82 |
83 | return decorator
84 |
--------------------------------------------------------------------------------
/backend/src/data/managers/mail_manager.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import smtplib
3 | import time
4 | from email import encoders
5 | from email.mime.base import MIMEBase
6 | from email.mime.multipart import MIMEMultipart
7 | from email.mime.text import MIMEText
8 | from typing import List, Optional
9 |
10 | from django.conf import settings
11 | from django.template.loader import get_template
12 |
13 | from src.data.clients import MailClient
14 |
15 | logger = logging.getLogger(__name__)
16 |
17 |
18 | class MailManager:
19 | def __init__(self, client: MailClient = MailClient()):
20 | self.client = client
21 |
22 | @staticmethod
23 | def _render_html(template_name: str, context: Optional[dict]) -> str:
24 | context = context or {}
25 | html_template = get_template(f"{template_name}.html")
26 | html_content = html_template.render(context)
27 |
28 | logger.info("HTML content rendered")
29 | return html_content
30 |
31 | @staticmethod
32 | def _attach_files(msg: MIMEMultipart, files: List[str]) -> None:
33 | for filename in files:
34 | try:
35 | with open(filename, "rb") as file:
36 | part = MIMEBase("application", "octet-stream")
37 | part.set_payload(file.read())
38 | encoders.encode_base64(part)
39 | part.add_header(
40 | "Content-Disposition",
41 | f"attachment; filename={filename}",
42 | )
43 | msg.attach(part)
44 | logger.info("File attached: %s", filename)
45 | except FileNotFoundError:
46 | logger.error(f"File not found: {filename}")
47 | except Exception as e:
48 | logger.error(f"Error attaching file {filename}: {str(e)}")
49 |
50 | def send_mail(
51 | self,
52 | subject: str,
53 | to_email: List[str],
54 | template_name: str,
55 | context: Optional[dict] = None,
56 | from_email: str = settings.EMAIL_HOST_USER,
57 | files: Optional[List[str]] = None,
58 | fail_silently: bool = False,
59 | ) -> bool:
60 | client = self.client.connect()
61 | try:
62 | html_content = self._render_html(template_name, context)
63 |
64 | msg = MIMEMultipart("alternative")
65 | msg["Subject"] = subject
66 | msg["From"] = from_email
67 | msg["To"] = ", ".join(to_email)
68 | msg.attach(MIMEText(html_content, "html"))
69 |
70 | if files:
71 | self._attach_files(msg, files)
72 |
73 | client.sendmail(from_email, to_email, msg.as_string())
74 | logger.info("Successfully sent email")
75 | return True
76 |
77 | except smtplib.SMTPException as error:
78 | logger.error(f"Failed to send email: {str(error)}")
79 | if not fail_silently:
80 | raise
81 | return False
82 |
83 | finally:
84 | self.client.disconnect()
85 |
--------------------------------------------------------------------------------
/backend/src/users/schemas/profile_schema.py:
--------------------------------------------------------------------------------
1 | from datetime import date
2 | from typing import Annotated, Optional
3 |
4 | from pydantic import (
5 | BaseModel,
6 | BeforeValidator,
7 | ConfigDict,
8 | HttpUrl,
9 | field_serializer,
10 | model_serializer,
11 | model_validator,
12 | )
13 | from pydantic.fields import Field
14 |
15 | from src.users.enums import CountryCodeEnum
16 | from src.users.validations import validate_birth_date, validate_code, validate_phone
17 |
18 |
19 | class PhoneNumberSchema(BaseModel):
20 | country_code: CountryCodeEnum
21 | number: str = Field(..., min_length=9, max_length=9)
22 |
23 | @model_serializer
24 | def serialize_model(self):
25 | return {
26 | "phone_number": f"{self.country_code.value}{self.number}",
27 | }
28 |
29 | @model_validator(mode="after")
30 | def validate_phone(self):
31 | if validate_phone(country_code=self.country_code.value, number=self.number):
32 | return self
33 |
34 | model_config = ConfigDict(
35 | json_schema_extra={
36 | "description": "Profile creation phone schema",
37 | "title": "RegisterPhoneSchema",
38 | "example": {
39 | "country_code": "+48",
40 | "number": "510100100",
41 | },
42 | }
43 | )
44 |
45 |
46 | class PhoneCodeSchema(BaseModel):
47 | token: str
48 | code: Annotated[str, BeforeValidator(validate_code)]
49 |
50 | model_config = ConfigDict(
51 | json_schema_extra={
52 | "description": "Profile code schema",
53 | "title": "CreatePhoneSchema",
54 | "example": {
55 | "token": "token",
56 | "code": "1234",
57 | },
58 | }
59 | )
60 |
61 |
62 | class ProfileCreateSchema(BaseModel):
63 | birth_date: Annotated[Optional[str], BeforeValidator(validate_birth_date)] = None
64 |
65 | model_config = ConfigDict(
66 | strict=True,
67 | json_schema_extra={
68 | "description": "Profile creation schema",
69 | "title": "ProfileCreate",
70 | "example": {
71 | "birth_date": "1990-01-01",
72 | },
73 | },
74 | )
75 |
76 |
77 | class ProfileUpdateSchema(BaseModel):
78 | birth_date: Annotated[Optional[date], BeforeValidator(validate_birth_date)] = None
79 |
80 | model_config = ConfigDict(
81 | json_schema_extra={
82 | "description": "Profile update schema",
83 | "title": "ProfileUpdate",
84 | "example": {
85 | "birth_date": "1990-02-02",
86 | },
87 | }
88 | )
89 |
90 |
91 | class ProfileSchema(BaseModel):
92 | birth_date: Optional[date]
93 | phone: Optional[str]
94 |
95 | model_config = ConfigDict(
96 | json_schema_extra={
97 | "description": "Profile schema",
98 | "title": "Profile",
99 | "example": {
100 | "birth_date": "1990-01-01",
101 | "phone": "+48510100100",
102 | },
103 | }
104 | )
105 |
106 |
107 | class SendCodeSchema(BaseModel):
108 | code: str
109 | token: str
110 |
111 | model_config = ConfigDict(
112 | json_schema_extra={
113 | "description": "Send code schema",
114 | "title": "SendCodeSchema",
115 | "example": {
116 | "code": "1234",
117 | "token": "token",
118 | },
119 | }
120 | )
121 |
--------------------------------------------------------------------------------
/backend/src/files/services.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from typing import TYPE_CHECKING, Optional, cast
3 | from uuid import UUID
4 |
5 | from ninja import UploadedFile
6 |
7 | from src.common import schemas as common_schemas
8 | from src.data.utils import resize_image
9 | from src.files import errors as file_errors
10 | from src.files import schemas as file_schemas
11 |
12 | if TYPE_CHECKING:
13 | from src.data.handlers import (
14 | AvatarFileHandler,
15 | )
16 | from src.data.interfaces import IFileHandler
17 |
18 | logger = logging.getLogger(__name__)
19 |
20 |
21 | class FileService:
22 | def __init__(
23 | self,
24 | avatar_handler: "IFileHandler",
25 | size: tuple[int, int] = (150, 150),
26 | ):
27 | self.size = size
28 | self.avatar_handler: "AvatarFileHandler" = cast(
29 | "AvatarFileHandler", avatar_handler
30 | )
31 |
32 | def get_avatar_url(self, avatar_key: str) -> Optional[str]:
33 | avatar_url = self.avatar_handler.get_avatar(object_key=avatar_key)
34 | if avatar_url:
35 | return avatar_url.split("?")[0]
36 | return None
37 |
38 | def _delete_avatar(self, avatar_key: str) -> Optional[bool]:
39 | return self.avatar_handler.delete_avatar(object_key=avatar_key)
40 |
41 | def _upload_avatar(self, file: UploadedFile, avatar_key: str) -> Optional[bool]:
42 | resized_file = resize_image(uploaded_file=file, size=self.size)
43 | return self.avatar_handler.upload_avatar(
44 | file=resized_file, object_key=avatar_key
45 | )
46 |
47 | def _exist_avatar(self, avatar_key: str) -> Optional[bool]:
48 | try:
49 | if self.get_avatar_url(avatar_key):
50 | return True
51 | except FileNotFoundError:
52 | return False
53 | except Exception as e:
54 | logger.exception(e)
55 | return False
56 |
57 | def get_avatar(self, user_id: UUID) -> Optional[file_schemas.AvatarSchema]:
58 | avatar_key = str(user_id)
59 | avatar = self.get_avatar_url(avatar_key)
60 | if avatar:
61 | return file_schemas.AvatarSchema(
62 | avatar=avatar,
63 | )
64 | raise file_errors.AvatarNotFound
65 |
66 | def upload_avatar(
67 | self, file: UploadedFile, user_id: UUID
68 | ) -> Optional[common_schemas.MessageSchema]:
69 | avatar_key = str(user_id)
70 | if self._exist_avatar(avatar_key):
71 | raise file_errors.AvatarExists
72 | if self._upload_avatar(file, avatar_key):
73 | return common_schemas.MessageSchema(message="Avatar uploaded successfully")
74 | raise file_errors.AvatarUploadFailed
75 |
76 | def update_avatar(
77 | self, file: UploadedFile, user_id: UUID
78 | ) -> Optional[common_schemas.MessageSchema]:
79 | avatar_key = str(user_id)
80 | if not self._exist_avatar(avatar_key):
81 | raise file_errors.AvatarNotFound
82 | if self._delete_avatar(avatar_key) and self._upload_avatar(file, avatar_key):
83 | return common_schemas.MessageSchema(message="Avatar updated successfully")
84 | raise file_errors.AvatarUpdateFailed
85 |
86 | def delete_avatar(self, user_id: UUID) -> Optional[common_schemas.MessageSchema]:
87 | avatar_key = str(user_id)
88 | if not self._exist_avatar(avatar_key):
89 | raise file_errors.AvatarNotFound
90 | if self._delete_avatar(avatar_key):
91 | return common_schemas.MessageSchema(message="Avatar deleted successfully")
92 | raise file_errors.AvatarDeleteFailed
93 |
--------------------------------------------------------------------------------
/backend/src/data/clients/celery_client.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import os
3 |
4 | from celery import Celery
5 | from celery.exceptions import CeleryError
6 | from django.apps import apps
7 | from django.conf import settings
8 | from kombu import Exchange, Queue
9 |
10 | from src.data.interfaces.client.abstract_client import IClient
11 |
12 | os.environ.setdefault(
13 | "DJANGO_SETTINGS_MODULE",
14 | os.getenv("DJANGO_SETTINGS_MODULE", "src.core.settings.dev"),
15 | )
16 |
17 |
18 | logger = logging.getLogger(__name__)
19 |
20 |
21 | class CeleryClient(IClient):
22 | def __init__(
23 | self,
24 | main_settings: str,
25 | broker_url: str,
26 | result_backend: str,
27 | timezone: str,
28 | *args,
29 | **kwargs,
30 | ):
31 | self.celery = self.connect(
32 | main_settings=main_settings,
33 | broker_url=broker_url,
34 | result_backend=result_backend,
35 | timezone=timezone,
36 | *args,
37 | **kwargs,
38 | )
39 |
40 | def connect(
41 | self,
42 | main_settings: str,
43 | broker_url: str,
44 | result_backend: str,
45 | timezone: str,
46 | *args,
47 | **kwargs,
48 | ) -> Celery:
49 | try:
50 | celery = Celery(
51 | main="src.core.celery",
52 | broker=broker_url,
53 | backend=result_backend,
54 | timezone=timezone,
55 | **kwargs,
56 | )
57 | celery.config_from_object(main_settings, namespace="CELERY")
58 |
59 | celery.conf.update(
60 | task_serializer="json",
61 | accept_content=["json"],
62 | result_serializer="json",
63 | timezone="Europe/Oslo",
64 | enable_utc=True,
65 | task_queues={
66 | "tasks": Queue(
67 | "tasks",
68 | Exchange("tasks"),
69 | routing_key="tasks",
70 | queue_arguments={"x-max-priority": 2},
71 | ),
72 | "events": Queue(
73 | "events",
74 | Exchange("events"),
75 | routing_key="events",
76 | queue_arguments={"x-max-priority": 1},
77 | ),
78 | "beats": Queue(
79 | "beats",
80 | Exchange("beats"),
81 | routing_key="beats",
82 | queue_arguments={"x-max-priority": 3},
83 | ),
84 | },
85 | result_extended=kwargs.get(
86 | "result_extended", celery.conf.result_extended
87 | ),
88 | task_time_limit=kwargs.get(
89 | "task_time_limit", celery.conf.task_time_limit
90 | ),
91 | task_soft_time_limit=kwargs.get(
92 | "task_soft_time_limit", celery.conf.task_soft_time_limit
93 | ),
94 | )
95 |
96 | celery.autodiscover_tasks(settings.INSTALLED_TASKS)
97 |
98 | self.celery = celery
99 | return self.celery
100 |
101 | except CeleryError as error:
102 | logger.error(f"Failed to connect to Celery: {error}")
103 | raise
104 |
105 | def disconnect(self, *args, **kwargs) -> None:
106 | if self.celery:
107 | self.celery.close()
108 |
--------------------------------------------------------------------------------
/backend/src/users/controllers.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from typing import Optional
3 | from uuid import UUID
4 |
5 | from django.http import HttpRequest, JsonResponse
6 | from ninja import UploadedFile
7 | from ninja.constants import NOT_SET
8 | from ninja_extra import api_controller, route
9 | from ninja_extra.exceptions import APIException
10 | from ninja_extra.permissions.common import IsAuthenticated
11 |
12 | from src.common.responses import ORJSONResponse
13 | from src.common.schemas import MessageSchema
14 | from src.core.config import get_phone_handler, get_storage
15 | from src.core.interceptors import AuthBearer
16 | from src.data.handlers import AvatarFileHandler, CacheHandler, EventHandler
17 | from src.data.managers import EventManager
18 | from src.data.storages import RedisStorage
19 | from src.files.services import FileService
20 | from src.users import errors as users_errors
21 | from src.users import schemas as users_schemas
22 | from src.users.repositories import ProfileRepository, UserRepository
23 | from src.users.services import ProfileService, UserService
24 |
25 | logger = logging.getLogger(__name__)
26 |
27 |
28 | @api_controller(
29 | prefix_or_class="/users",
30 | auth=NOT_SET,
31 | permissions=[],
32 | tags=["users"],
33 | )
34 | class UsersController:
35 | user_repository = UserRepository()
36 | profile_repository = ProfileRepository()
37 | avatar_handler = AvatarFileHandler(storage=get_storage())
38 | cache_handler = CacheHandler(pool_storage=RedisStorage())
39 | event_handler = EventHandler(manager=EventManager())
40 | phone_handler = get_phone_handler(
41 | cache=cache_handler,
42 | repository=profile_repository,
43 | )
44 |
45 | file_service = FileService(
46 | avatar_handler=avatar_handler,
47 | )
48 |
49 | user_service = UserService(
50 | repository=user_repository,
51 | avatar_handler=avatar_handler,
52 | event_handler=event_handler,
53 | )
54 |
55 | profile_service = ProfileService(
56 | repository=profile_repository,
57 | event_handler=event_handler,
58 | cache_handler=cache_handler,
59 | avatar_handler=avatar_handler,
60 | phone_handler=phone_handler,
61 | )
62 |
63 | @route.get(
64 | "/account",
65 | auth=AuthBearer(),
66 | )
67 | def get_account(
68 | self,
69 | request: HttpRequest,
70 | ):
71 | try:
72 | user_schema = self.user_service.get_user(user_id=request.user.pk)
73 | if user_schema:
74 | profile_schema = self.profile_service.get_profile(
75 | user_id=request.user.pk
76 | )
77 | avatar = self.file_service.get_avatar_url(avatar_key=request.user.pk)
78 | return ORJSONResponse(
79 | data={
80 | **user_schema.model_dump(),
81 | **profile_schema.model_dump(),
82 | "avatar": avatar,
83 | },
84 | status=200,
85 | )
86 |
87 | except APIException:
88 | raise users_errors.UserNotFound
89 |
90 | @route.post(
91 | path="/update/email",
92 | auth=AuthBearer(),
93 | permissions=[IsAuthenticated],
94 | )
95 | def change_email(
96 | self,
97 | request: HttpRequest,
98 | email_update_schema: users_schemas.EmailUpdateSchema,
99 | ):
100 | return ORJSONResponse(
101 | data=self.user_service.change_email(
102 | email_update_schema=email_update_schema,
103 | user_id=request.user.pk,
104 | ).model_dump(),
105 | status=200,
106 | )
107 |
--------------------------------------------------------------------------------
/backend/src/core/settings/base.py:
--------------------------------------------------------------------------------
1 | from django.apps import apps
2 |
3 | from src.core.config import BASE_DIR
4 |
5 | DJANGO_APPS = [
6 | "django.contrib.admin",
7 | "django.contrib.auth",
8 | "django.contrib.contenttypes",
9 | "django.contrib.sessions",
10 | "django.contrib.messages",
11 | "django.contrib.staticfiles",
12 | ]
13 |
14 | THIRD_PARTY_APPS = [
15 | "ninja_extra",
16 | "corsheaders",
17 | ]
18 |
19 | CREATE_APPS = [
20 | "src.common",
21 | "src.users",
22 | "src.events",
23 | "src.products",
24 | "src.orders",
25 | "src.reviews",
26 | "src.categories",
27 | ]
28 |
29 | INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + CREATE_APPS
30 |
31 | INSTALLED_TASKS = [
32 | "src.data",
33 | "src.auth",
34 | *[app for app in CREATE_APPS if app.startswith("src.")],
35 | ]
36 |
37 | # EVENT PUB/SUB
38 | WORKING_SUBSCRIBERS = []
39 |
40 | WORKING_HANDLERS = [
41 | "src.users.controllers.UsersController.profile_service.handle_user_created",
42 | ]
43 |
44 | MIDDLEWARE = [
45 | "django.middleware.security.SecurityMiddleware",
46 | "django.contrib.sessions.middleware.SessionMiddleware",
47 | "corsheaders.middleware.CorsMiddleware",
48 | "django.middleware.common.CommonMiddleware",
49 | "django.middleware.csrf.CsrfViewMiddleware",
50 | "django.contrib.auth.middleware.AuthenticationMiddleware",
51 | "django.contrib.messages.middleware.MessageMiddleware",
52 | ]
53 |
54 | ROOT_URLCONF = "src.api"
55 |
56 | AUTH_USER_MODEL = "users.User"
57 |
58 | TEMPLATES = [
59 | {
60 | "BACKEND": "django.template.backends.django.DjangoTemplates",
61 | "DIRS": [BASE_DIR / "templates"],
62 | "APP_DIRS": True,
63 | "OPTIONS": {
64 | "context_processors": [
65 | "django.template.context_processors.debug",
66 | "django.template.context_processors.request",
67 | "django.contrib.auth.context_processors.auth",
68 | "django.template.context_processors.i18n",
69 | "django.template.context_processors.media",
70 | "django.template.context_processors.static",
71 | "django.template.context_processors.tz",
72 | "django.contrib.messages.context_processors.messages",
73 | ],
74 | },
75 | },
76 | ]
77 |
78 | # Default primary key field type
79 | # https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field
80 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
81 |
82 | # Password validation
83 | # https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators
84 |
85 | AUTH_PASSWORD_VALIDATORS = [
86 | {
87 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"
88 | },
89 | {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
90 | {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
91 | {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
92 | ]
93 |
94 | # AUTHENTICATION
95 | # ------------------------------------------------------------------------------
96 | # https://docs.djangoproject.com/en/dev/ref/settings/#authentication-backends
97 | AUTHENTICATION_BACKENDS = ["django.contrib.auth.backends.ModelBackend"]
98 |
99 | # PASSWORDS
100 | # ------------------------------------------------------------------------------
101 | # https://docs.djangoproject.com/en/dev/ref/settings/#password-hashers
102 | PASSWORD_HASHERS = [
103 | "django.contrib.auth.hashers.BCryptSHA256PasswordHasher",
104 | ]
105 |
106 | # Internationalization
107 | # https://docs.djangoproject.com/en/4.2/topics/i18n/
108 |
109 | LANGUAGE_CODE = "en-us"
110 |
111 | TIME_ZONE = "UTC"
112 |
113 | USE_I18N = True
114 |
115 | USE_TZ = True
116 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | wheels/
23 | share/python-wheels/
24 | *.egg-info/
25 | .installed.cfg
26 | *.egg
27 | MANIFEST
28 |
29 | # PyInstaller
30 | # Usually these files are written by a python script from a template
31 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
32 | *.manifest
33 | *.spec
34 |
35 | # Installer logs
36 | pip-log.txt
37 | pip-delete-this-directory.txt
38 |
39 | # Unit test / coverage reports
40 | htmlcov/
41 | .tox/
42 | .nox/
43 | .coverage
44 | .coverage.*
45 | .cache
46 | nosetests.xml
47 | coverage.xml
48 | *.cover
49 | *.py,cover
50 | .hypothesis/
51 | .pytest_cache/
52 | cover/
53 |
54 | # Translations
55 | *.mo
56 | *.pot
57 |
58 | # Django stuff:
59 | *.log
60 | local_settings.py
61 | db.sqlite3
62 | db.sqlite3-journal
63 |
64 | # Flask stuff:
65 | instance/
66 | .webassets-cache
67 |
68 | # Scrapy stuff:
69 | .scrapy
70 |
71 | # Sphinx documentation
72 | docs/_build/
73 |
74 | # PyBuilder
75 | .pybuilder/
76 | target/
77 |
78 | # Jupyter Notebook
79 | .ipynb_checkpoints
80 |
81 | # IPython
82 | profile_default/
83 | ipython_config.py
84 |
85 | # pyenv
86 | # For a library or package, you might want to ignore these files since the code is
87 | # intended to run in multiple environments; otherwise, check them in:
88 | # .python-version
89 |
90 | # pipenv
91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
93 | # having no cross-platform support, pipenv may install.sh dependencies that don't work, or not
94 | # install.sh all needed dependencies.
95 | #Pipfile.lock
96 |
97 | # poetry
98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
99 | # This is especially recommended for binary packages to ensure reproducibility, and is more
100 | # commonly ignored for libraries.
101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
102 | #poetry.lock
103 |
104 | # pdm
105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
106 | #pdm.lock
107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
108 | # in version control.
109 | # https://pdm.fming.dev/#use-with-ide
110 | .pdm.toml
111 |
112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
113 | __pypackages__/
114 |
115 | # Celery stuff
116 | celerybeat-schedule
117 | celerybeat.pid
118 |
119 | # SageMath parsed files
120 | *.sage.py
121 |
122 | # Environments
123 | .env
124 | .venv
125 | env/
126 | venv/
127 | ENV/
128 | env.bak/
129 | venv.bak/
130 |
131 | # Spyder project settings
132 | .spyderproject
133 | .spyproject
134 |
135 | # Rope project settings
136 | .ropeproject
137 |
138 | # mkdocs documentation
139 | /site
140 |
141 | # mypy
142 | .mypy_cache/
143 | .dmypy.json
144 | dmypy.json
145 |
146 | # Pyre type checker
147 | .pyre/
148 |
149 | # pytype static type analyzer
150 | .pytype/
151 |
152 | # Cython debug symbols
153 | cython_debug/
154 |
155 | # PyCharm
156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
158 | # and can be added to the global gitignore or merged into this file. For a more nuclear
159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder.
160 | .idea/
161 | bruno/
162 |
--------------------------------------------------------------------------------
/backend/src/users/old_controllers/user_controller.py:
--------------------------------------------------------------------------------
1 | # import logging
2 | # from typing import Optional
3 | # from uuid import UUID
4 | #
5 | # from django.http import HttpRequest, JsonResponse
6 | # from ninja import UploadedFile
7 | # from ninja.constants import NOT_SET
8 | # from ninja_extra import api_controller, route
9 | # from ninja_extra.exceptions import APIException
10 | # from ninja_extra.permissions.common import IsAuthenticated
11 | #
12 | # from src.common.schemas import MessageSchema
13 | # from src.core.interceptors import AuthBearer
14 | # from src.core.storage import get_storage
15 | # from src.data.handlers import AvatarFileHandler, EventHandler
16 | # from src.data.managers import EventManager
17 | # from src.users.errors import UserNotFound
18 | # from src.users.repositories import UserRepository
19 | # from src.users.schemas import (
20 | # EmailUpdateErrorSchema,
21 | # EmailUpdateSchema,
22 | # EmailUpdateSuccessSchema,
23 | # ProfileUpdateSchema,
24 | # UserGetSuccess,
25 | # UserProfileErrorSchema,
26 | # UserProfileSuccessSchema,
27 | # UserProfileUpdateSchema,
28 | # UserUpdateSchema,
29 | # )
30 | # from src.users.services import UserService
31 | #
32 | # logger = logging.getLogger(__name__)
33 | #
34 | #
35 | # @api_controller(
36 | # prefix_or_class="/users",
37 | # auth=NOT_SET,
38 | # permissions=[],
39 | # tags=["users"],
40 | # )
41 | # class UserController:
42 | # repository = UserRepository()
43 | # avatar_handler = AvatarFileHandler(storage=get_storage())
44 | # event_handler = EventHandler(manager=EventManager())
45 | # service = UserService(
46 | # repository=repository,
47 | # avatar_handler=avatar_handler,
48 | # event_handler=event_handler,
49 | # )
50 | #
51 | # @route.get(
52 | # "/{username}",
53 | # )
54 | # def get_user(
55 | # self,
56 | # username: str,
57 | # ):
58 | # return self.service.get_user(username=username)
59 | #
60 | # @route.post(
61 | # "/update/email",
62 | # auth=AuthBearer(),
63 | # permissions=[IsAuthenticated],
64 | # response={
65 | # 200: EmailUpdateSuccessSchema,
66 | # 400: EmailUpdateErrorSchema,
67 | # },
68 | # )
69 | # def change_email(
70 | # self,
71 | # request: HttpRequest,
72 | # email_update: EmailUpdateSchema,
73 | # ):
74 | # return self.service.change_email(
75 | # email_update=email_update,
76 | # user_id=request.user.pk,
77 | # )
78 | #
79 | # @route.post(
80 | # "/update",
81 | # auth=AuthBearer(),
82 | # permissions=[IsAuthenticated],
83 | # response={
84 | # 200: UserProfileSuccessSchema,
85 | # 400: UserProfileErrorSchema,
86 | # },
87 | # )
88 | # def update_user(
89 | # self,
90 | # request: HttpRequest,
91 | # user_update: UserProfileUpdateSchema,
92 | # ):
93 | # self.service.update_user(
94 | # profile_update=ProfileUpdateSchema(
95 | # **user_update.model_dump(include={"birth_date"})
96 | # ),
97 | # user_update=UserUpdateSchema(
98 | # **user_update.model_dump(
99 | # include={"first_name", "last_name", "username"}
100 | # )
101 | # ),
102 | # user_id=request.user.pk,
103 | # )
104 | # response = self.service.event.subscribe(
105 | # event_name="profile_updated", with_receive=False
106 | # )
107 | # is_updated = response.get("is_updated")
108 | #
109 | # if is_updated:
110 | # return MessageSchema(message="User updated successfully")
111 | # return MessageSchema(message="User was not updated")
112 |
--------------------------------------------------------------------------------
/frontend/src/assets/react.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/backend/src/auth/utils.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import uuid
3 | from datetime import datetime, timedelta
4 | from typing import Optional
5 |
6 | import jwt
7 | from django.conf import settings
8 | from django.contrib.auth.hashers import check_password, make_password
9 | from ninja_extra.exceptions import APIException
10 |
11 | from src.auth import errors as auth_errors
12 | from src.auth.errors import (
13 | InvalidCredentials,
14 | InvalidToken,
15 | TokenExpired,
16 | )
17 |
18 | logger = logging.getLogger(__name__)
19 |
20 |
21 | def check_passwords_match(
22 | password: str,
23 | hashed_password: str,
24 | ) -> bool:
25 | return check_password(password, hashed_password)
26 |
27 |
28 | def hash_password(password: str) -> str:
29 | password = make_password(password)
30 |
31 | return password
32 |
33 |
34 | def create_jwt_token(data: dict, expires_delta: timedelta) -> str:
35 | to_encode = data.copy()
36 |
37 | to_encode.update(
38 | {
39 | "iat": datetime.utcnow(),
40 | "exp": datetime.utcnow() + expires_delta,
41 | "jti": str(uuid.uuid4()),
42 | }
43 | )
44 | encoded_jwt = jwt.encode(
45 | to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM
46 | )
47 | return encoded_jwt
48 |
49 |
50 | def decode_jwt_token(token: str) -> Optional[dict]:
51 | try:
52 | payload = jwt.decode(
53 | token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
54 | )
55 | logger.info("Token decoded: %s", payload)
56 | return payload
57 | except jwt.ExpiredSignatureError:
58 | raise TokenExpired
59 | except jwt.InvalidTokenError:
60 | raise InvalidToken
61 | except jwt.PyJWTError:
62 | raise InvalidCredentials
63 | except APIException:
64 | raise auth_errors.InvalidToken
65 |
66 |
67 | def get_access_token(
68 | username: str,
69 | user_id: uuid.UUID,
70 | token_type: str = "access",
71 | ) -> str:
72 | access_token_expires = timedelta(minutes=float(settings.ACCESS_TOKEN_EXPIRE))
73 | access_token = create_jwt_token(
74 | data={
75 | "username": username,
76 | "user_id": str(user_id),
77 | "token_type": token_type,
78 | },
79 | expires_delta=access_token_expires,
80 | )
81 | logger.info(
82 | "Access token created: [green]%s[/] for [blue]%s[/] minutes",
83 | access_token,
84 | int(access_token_expires.total_seconds()) // 60,
85 | extra={"markup": True},
86 | )
87 | return access_token
88 |
89 |
90 | def get_refresh_token(
91 | username: str,
92 | user_id: uuid.UUID,
93 | token_type: str = "refresh",
94 | ) -> str:
95 | refresh_token_expires = timedelta(minutes=float(settings.REFRESH_TOKEN_EXPIRE))
96 | refresh_token = create_jwt_token(
97 | data={
98 | "username": username,
99 | "user_id": str(user_id),
100 | "token_type": token_type,
101 | },
102 | expires_delta=refresh_token_expires,
103 | )
104 | logger.info(
105 | "Refresh token created: [green]%s[/] for [blue]%s[/] minutes",
106 | refresh_token,
107 | int(refresh_token_expires.total_seconds()) // 60,
108 | extra={"markup": True},
109 | )
110 | return refresh_token
111 |
112 |
113 | def encode_jwt_token(username: str, user_id: uuid.UUID) -> dict:
114 | access_token = get_access_token(username, user_id)
115 | refresh_token = get_refresh_token(username, user_id)
116 | data = {
117 | "access_token": access_token,
118 | "refresh_token": refresh_token,
119 | "token_type": "bearer",
120 | }
121 | return data
122 |
123 |
124 | def get_backend_url(add_path: str = "") -> str:
125 | if settings.DJANGO_HOST == "0.0.0.0":
126 | host = "127.0.0.1"
127 | port = settings.DJANGO_PORT
128 | base_url = f"http://{host}:{port}"
129 | else:
130 | base_url = f"{settings.API_BACKEND_URL}"
131 | return f"{base_url}{add_path}"
132 |
--------------------------------------------------------------------------------
/backend/src/data/utils.py:
--------------------------------------------------------------------------------
1 | import io
2 | import logging
3 | import mimetypes
4 | import pathlib
5 | import posixpath
6 | from mimetypes import guess_type
7 | from typing import BinaryIO, Optional, TypeVar, Union
8 | from uuid import UUID
9 |
10 | from django.conf import settings
11 | from django.core.files.base import ContentFile
12 | from django.core.files.uploadedfile import InMemoryUploadedFile
13 | from ninja import UploadedFile
14 | from PIL import Image
15 |
16 | logger = logging.getLogger(__name__)
17 |
18 | ObjectType = TypeVar("ObjectType", bound=Union[UUID, str, int])
19 |
20 |
21 | def clean_name(name):
22 | if isinstance(name, pathlib.PurePath):
23 | name = str(name)
24 |
25 | # Normalize Windows style paths
26 | cln_name = posixpath.normpath(name).replace("\\", "/")
27 |
28 | # os.path.normpath() can strip trailing slashes so we implement
29 | # a workaround here.
30 | if name.endswith("/") and not cln_name.endswith("/"):
31 | # Add a trailing slash as it was stripped.
32 | cln_name += "/"
33 |
34 | # Given an empty string, os.path.normpath() will return ., which we don't want
35 | if cln_name == ".":
36 | cln_name = ""
37 |
38 | return cln_name
39 |
40 |
41 | def path_file(
42 | filename: str,
43 | folder: str,
44 | object_key: Optional[ObjectType] = None,
45 | ) -> BinaryIO:
46 | full_object_path = f"{folder}/{filename}"
47 | if object_key:
48 | full_object_path = f"{folder}/{object_key}/{filename}"
49 |
50 | file_path = settings.BASE_DIR / full_object_path
51 | return open(file_path, "rb")
52 |
53 |
54 | def upload_file(
55 | filename: str,
56 | file: UploadedFile | BinaryIO,
57 | content_type: Optional[str] = None,
58 | ) -> UploadedFile:
59 | file_io = io.BytesIO()
60 | if isinstance(file, UploadedFile):
61 | contents = file.file.read()
62 | file_io.write(contents)
63 | file_size = len(contents)
64 | else:
65 | contents = file.read()
66 | file_io.write(contents)
67 | file_io.seek(0, io.SEEK_END)
68 | file_size = file_io.tell()
69 | file_io.seek(0)
70 |
71 | filename = clean_name(filename)
72 | if content_type:
73 | filename = filename.split(".")[0] + "." + content_type.split("/")[1]
74 |
75 | return UploadedFile(
76 | file=file_io,
77 | name=filename,
78 | content_type=content_type or mimetypes.guess_type(filename)[0],
79 | size=file_size,
80 | )
81 |
82 |
83 | def get_file_size(file: UploadedFile) -> int:
84 | return file.size
85 |
86 |
87 | def get_content_type(file: UploadedFile) -> str:
88 | return guess_type(file.name)[0]
89 |
90 |
91 | def get_file_io(file: UploadedFile) -> io.BytesIO:
92 | file.file.seek(0)
93 | data = file.file.read()
94 | file_io = io.BytesIO(data)
95 | file_io.seek(0)
96 | return file_io
97 |
98 |
99 | def get_extension(content_type: str, custom_types: dict = None):
100 | if custom_types is None:
101 | custom_types = {
102 | "image/webp": ".webp",
103 | }
104 |
105 | if content_type in custom_types:
106 | return custom_types[content_type]
107 |
108 | ext = mimetypes.guess_extension(content_type)
109 |
110 | if ext is None:
111 | ext = "." + content_type.split("/")[-1]
112 |
113 | return ext
114 |
115 |
116 | def resize_image(uploaded_file: UploadedFile, size=(200, 200)) -> UploadedFile:
117 | uploaded_file.file.seek(0)
118 | original_data = uploaded_file.file.read()
119 |
120 | with Image.open(io.BytesIO(original_data)) as image:
121 | image.thumbnail(size, Image.Resampling.LANCZOS)
122 |
123 | output = io.BytesIO()
124 | image.save(output, format=image.format)
125 | output_data = output.getvalue()
126 | logger.info("Resized image size: %s", len(output_data))
127 |
128 | content_file = ContentFile(output_data)
129 |
130 | new_file = UploadedFile(
131 | file=content_file,
132 | name=uploaded_file.name,
133 | content_type=uploaded_file.content_type,
134 | size=len(output_data),
135 | )
136 |
137 | return new_file
138 |
--------------------------------------------------------------------------------
/backend/media/files/country_codes.csv:
--------------------------------------------------------------------------------
1 | Country,Code,Calling Code
2 | Afghanistan,AF,+93
3 | Albania,AL,+355
4 | Algeria,DZ,+213
5 | Andorra,AD,+376
6 | Angola,AO,+244
7 | Antigua and Barbuda,AG,+1-268
8 | Argentina,AR,+54
9 | Armenia,AM,+374
10 | Australia,AU,+61
11 | Austria,AT,+43
12 | Azerbaijan,AZ,+994
13 | Bahamas,BS,+1-242
14 | Bahrain,BH,+973
15 | Bangladesh,BD,+880
16 | Barbados,BB,+1-246
17 | Belarus,BY,+375
18 | Belgium,BE,+32
19 | Belize,BZ,+501
20 | Benin,BJ,+229
21 | Bhutan,BT,+975
22 | Bolivia,BO,+591
23 | Bosnia and Herzegovina,BA,+387
24 | Botswana,BW,+267
25 | Brazil,BR,+55
26 | Brunei,BN,+673
27 | Bulgaria,BG,+359
28 | Burkina Faso,BF,+226
29 | Burundi,BI,+257
30 | Cambodia,KH,+855
31 | Cameroon,CM,+237
32 | Canada,CA,+1
33 | Cape Verde,CV,+238
34 | Central African Republic,CF,+236
35 | Chad,TD,+235
36 | Chile,CL,+56
37 | China,CN,+86
38 | Colombia,CO,+57
39 | Comoros,KM,+269
40 | Congo,CG,+242
41 | Costa Rica,CR,+506
42 | Croatia,HR,+385
43 | Cuba,CU,+53
44 | Cyprus,CY,+357
45 | Czech Republic,CZ,+420
46 | Denmark,DK,+45
47 | Djibouti,DJ,+253
48 | Dominica,DM,+1-767
49 | Dominican Republic,DO,+1-809, 1-829, 1-849
50 | East Timor,TL,+670
51 | Ecuador,EC,+593
52 | Egypt,EG,+20
53 | El Salvador,SV,+503
54 | Equatorial Guinea,GQ,+240
55 | Eritrea,ER,+291
56 | Estonia,EE,+372
57 | Eswatini,SZ,+268
58 | Ethiopia,ET,+251
59 | Fiji,FJ,+679
60 | Finland,FI,+358
61 | France,FR,+33
62 | Gabon,GA,+241
63 | Gambia,GM,+220
64 | Georgia,GE,+995
65 | Germany,DE,+49
66 | Ghana,GH,+233
67 | Greece,GR,+30
68 | Grenada,GD,+1-473
69 | Guatemala,GT,+502
70 | Guinea,GN,+224
71 | Guinea-Bissau,GW,+245
72 | Guyana,GY,+592
73 | Haiti,HT,+509
74 | Honduras,HN,+504
75 | Hungary,HU,+36
76 | Iceland,IS,+354
77 | India,IN,+91
78 | Indonesia,ID,+62
79 | Iran,IR,+98
80 | Iraq,IQ,+964
81 | Ireland,IE,+353
82 | Israel,IL,+972
83 | Italy,IT,+39
84 | Jamaica,JM,+1-876
85 | Japan,JP,+81
86 | Jordan,JO,+962
87 | Kazakhstan,KZ,+7
88 | Kenya,KE,+254
89 | Kiribati,KI,+686
90 | Kuwait,KW,+965
91 | Kyrgyzstan,KG,+996
92 | Laos,LA,+856
93 | Latvia,LV,+371
94 | Lebanon,LB,+961
95 | Lesotho,LS,+266
96 | Liberia,LR,+231
97 | Libya,LY,+218
98 | Liechtenstein,LI,+423
99 | Lithuania,LT,+370
100 | Luxembourg,LU,+352
101 | Madagascar,MG,+261
102 | Malawi,MW,+265
103 | Malaysia,MY,+60
104 | Maldives,MV,+960
105 | Mali,ML,+223
106 | Malta,MT,+356
107 | Marshall Islands,MH,+692
108 | Mauritania,MR,+222
109 | Mauritius,MU,+230
110 | Mexico,MX,+52
111 | Micronesia,FM,+691
112 | Moldova,MD,+373
113 | Monaco,MC,+377
114 | Mongolia,MN,+976
115 | Montenegro,ME,+382
116 | Morocco,MA,+212
117 | Mozambique,MZ,+258
118 | Myanmar,MM,+95
119 | Namibia,NA,+264
120 | Nauru,NR,+674
121 | Nepal,NP,+977
122 | Netherlands,NL,+31
123 | New Zealand,NZ,+64
124 | Nicaragua,NI,+505
125 | Niger,NE,+227
126 | Nigeria,NG,+234
127 | North Korea,KP,+850
128 | North Macedonia,MK,+389
129 | Norway,NO,+47
130 | Oman,OM,+968
131 | Pakistan,PK,+92
132 | Palau,PW,+680
133 | Panama,PA,+507
134 | Papua New Guinea,PG,+675
135 | Paraguay,PY,+595
136 | Peru,PE,+51
137 | Philippines,PH,+63
138 | Poland,PL,+48
139 | Portugal,PT,+351
140 | Qatar,QA,+974
141 | Romania,RO,+40
142 | Russia,RU,+7
143 | Rwanda,RW,+250
144 | Saint Kitts and Nevis,KN,+1-869
145 | Saint Lucia,LC,+1-758
146 | Saint Vincent and the Grenadines,VC,+1-784
147 | Samoa,WS,+685
148 | San Marino,SM,+378
149 | Sao Tome and Principe,ST,+239
150 | Saudi Arabia,SA,+966
151 | Senegal,SN,+221
152 | Serbia,RS,+381
153 | Seychelles,SC,+248
154 | Sierra Leone,SL,+232
155 | Singapore,SG,+65
156 | Slovakia,SK,+421
157 | Slovenia,SI,+386
158 | Solomon Islands,SB,+677
159 | Somalia,SO,+252
160 | South Africa,ZA,+27
161 | South Korea,KR,+82
162 | South Sudan,SS,+211
163 | Spain,ES,+34
164 | Sri Lanka,LK,+94
165 | Sudan,SD,+249
166 | Suriname,SR,+597
167 | Sweden,SE,+46
168 | Switzerland,CH,+41
169 | Syria,SY,+963
170 | Taiwan,TW,+886
171 | Tajikistan,TJ,+992
172 | Tanzania,TZ,+255
173 | Thailand,TH,+66
174 | Togo,TG,+228
175 | Tonga,TO,+676
176 | Trinidad and Tobago,TT,+1-868
177 | Tunisia,TN,+216
178 | Turkey,TR,+90
179 | Turkmenistan,TM,+993
180 | Tuvalu,TV,+688
181 | Uganda,UG,+256
182 | Ukraine,UA,+380
183 | United Arab Emirates,AE,+971
184 | United Kingdom,GB,+44
185 | United States,US,+1
186 | Uruguay,UY,+598
187 | Uzbekistan,UZ,+998
188 | Vanuatu,VU,+678
189 | Vatican City,VA,+39
190 | Venezuela,VE,+58
191 | Vietnam,VN,+84
192 | Yemen,YE,+967
193 | Zambia,ZM,+260
194 | Zimbabwe,ZW,+263
195 |
--------------------------------------------------------------------------------
/backend/src/auth/controllers/auth_controller.py:
--------------------------------------------------------------------------------
1 | from django.http import HttpRequest
2 | from ninja.constants import NOT_SET
3 | from ninja_extra import api_controller, permissions, route, throttle
4 |
5 | from src.auth import schemas as auth_schemas
6 | from src.auth.services import AuthService
7 | from src.auth.throttles import RegisterMailThrottle, RegisterThrottle
8 | from src.common import permissions as common_permissions
9 | from src.common import schemas as common_schemas
10 | from src.common.responses import ORJSONResponse
11 | from src.core.config import get_phone_handler, get_storage
12 | from src.core.interceptors import AuthBearer
13 | from src.data.handlers import (
14 | AvatarFileHandler,
15 | CacheHandler,
16 | EventHandler,
17 | ImageFileHandler,
18 | RegistrationEmailHandler,
19 | )
20 | from src.data.managers import EventManager, MailManager
21 | from src.data.storages import RedisStorage
22 | from src.users.repositories import ProfileRepository, UserRepository
23 | from src.users.services import ProfileService, UserService
24 |
25 |
26 | @api_controller(
27 | prefix_or_class="/auth",
28 | auth=NOT_SET,
29 | permissions=[],
30 | tags=["auth"],
31 | )
32 | class AuthController:
33 | user_repository = UserRepository()
34 | profile_repository = ProfileRepository()
35 | cache_handler = CacheHandler(pool_storage=RedisStorage())
36 | event_handler = EventHandler(manager=EventManager())
37 | image_handler = ImageFileHandler(storage=get_storage())
38 |
39 | service = AuthService(
40 | cache_handler=cache_handler,
41 | event_handler=event_handler,
42 | image_handler=image_handler,
43 | user_service=UserService(
44 | repository=user_repository,
45 | event_handler=event_handler,
46 | ),
47 | profile_service=ProfileService(
48 | repository=profile_repository,
49 | event_handler=event_handler,
50 | cache_handler=cache_handler,
51 | ),
52 | )
53 |
54 | @route.post(
55 | "/register/mail",
56 | permissions=[common_permissions.LoggedOutOnly],
57 | )
58 | @throttle(RegisterMailThrottle)
59 | def register_mail_view(
60 | self,
61 | user_register_mail_schema: auth_schemas.RegisterUserMailSchema,
62 | ):
63 | url_schema = self.service.register_user_mail(
64 | user_register_mail_schema=user_register_mail_schema,
65 | )
66 | return ORJSONResponse(
67 | data=url_schema.model_dump(),
68 | status=200,
69 | )
70 |
71 | @route.post(
72 | "/register/mail/{token}",
73 | permissions=[common_permissions.LoggedOutOnly],
74 | )
75 | @throttle(RegisterThrottle)
76 | def register_view(
77 | self,
78 | token: str,
79 | register_schema: auth_schemas.RegisterSchema,
80 | ):
81 | register_user_schema = self.service.register_user(
82 | token=token,
83 | register_schema=register_schema,
84 | )
85 | return ORJSONResponse(
86 | data=register_user_schema.model_dump(),
87 | status=200,
88 | )
89 |
90 | @route.post(
91 | "/login",
92 | permissions=[common_permissions.LoggedOutOnly],
93 | )
94 | def login_view(
95 | self,
96 | login_schema: auth_schemas.LoginSchema,
97 | ):
98 | return ORJSONResponse(
99 | data=self.service.login_user(
100 | username=login_schema.username,
101 | password=login_schema.password,
102 | ).model_dump(),
103 | status=200,
104 | )
105 |
106 | @route.post(
107 | "/refresh",
108 | )
109 | def refresh_view(
110 | self,
111 | refresh_token_schema: auth_schemas.RefreshTokenSchema,
112 | ):
113 | return ORJSONResponse(
114 | data=self.service.refresh_token(
115 | refresh_token=refresh_token_schema.refresh_token,
116 | ).model_dump(),
117 | status=200,
118 | )
119 |
120 | @route.post(
121 | "/logout",
122 | auth=AuthBearer(),
123 | )
124 | def logout_view(
125 | self,
126 | request: HttpRequest,
127 | ):
128 | request.auth = None
129 | request.user = None
130 | return ORJSONResponse(
131 | data=common_schemas.MessageSchema(message="Logout successful").model_dump(),
132 | status=200,
133 | )
134 |
--------------------------------------------------------------------------------
/backend/src/data/handlers/event_handler.py:
--------------------------------------------------------------------------------
1 | import importlib
2 | import logging
3 | import threading
4 | from typing import TYPE_CHECKING, Optional
5 |
6 | from django.conf import settings
7 |
8 | from src.data.interfaces import IEventHandler
9 |
10 | if TYPE_CHECKING:
11 | from src.data.interfaces import IEventManager
12 |
13 | logger = logging.getLogger(__name__)
14 |
15 |
16 | class EventHandler(IEventHandler):
17 | def __init__(
18 | self,
19 | manager: "IEventManager",
20 | ):
21 | self.manager = manager
22 |
23 | def start_handlers(self) -> None:
24 | if bool(settings.WORKING_HANDLERS or settings.WORKING_HANDLERS != []):
25 | for event_class in settings.WORKING_HANDLERS:
26 | module_path, class_name, service_name, method_name = event_class.rsplit(
27 | sep=".", maxsplit=3
28 | )
29 | controller = getattr(importlib.import_module(module_path), class_name)
30 | services_methods = [
31 | getattr(controller, x)
32 | for x in dir(controller)
33 | if "service" in x
34 | and hasattr(getattr(controller, x), method_name)
35 | and callable(getattr(getattr(controller, x), method_name))
36 | ]
37 | for service in services_methods:
38 | method = getattr(service, method_name)
39 |
40 | if method.__name__.startswith("handle"):
41 | event_name = method.__name__.replace("handle_", "")
42 | logger.info(
43 | "Starting [green]%s[/] for event: [yellow]%s[/] in service: [bold red blink]%s[/]",
44 | method.__name__,
45 | event_name,
46 | service.__class__.__name__,
47 | extra={"markup": True},
48 | )
49 | threading.Thread(target=method).start()
50 |
51 | def start_subscribers(self) -> None:
52 | if bool(settings.WORKING_SUBSCRIBERS or settings.WORKING_SUBSCRIBERS != []):
53 | for subscriber in settings.WORKING_SUBSCRIBERS:
54 | (
55 | logger.info(
56 | "Starting subscriber event: [yellow]%s[/]",
57 | subscriber,
58 | extra={"markup": True},
59 | ),
60 | )
61 | threading.Thread(target=self.subscribe, args=(subscriber,)).start()
62 | else:
63 | logger.info(
64 | "start_subscribers: No subscribers found",
65 | extra={"markup": True},
66 | )
67 |
68 | def publish(self, event_name: str, event_data: str | dict) -> None:
69 | self.manager.publish(event_name=event_name, event_data=event_data)
70 |
71 | def subscribe(
72 | self,
73 | event_name: str,
74 | ) -> None:
75 | self.manager.subscribe(event_name=event_name)
76 |
77 | def _process_event(
78 | self,
79 | event_name: str,
80 | timeout: Optional[None | float] = None,
81 | ) -> Optional[dict]:
82 | try:
83 | # Receive event data using the manager
84 | event_data = self.manager.receive(
85 | event_name=event_name,
86 | timeout=timeout,
87 | )
88 |
89 | # Check if the event data is valid and return it
90 | if event_data and isinstance(event_data, dict):
91 | return event_data
92 | else:
93 | logger.warning("Received invalid event data for event: %s", event_name)
94 | return None
95 | except Exception as e:
96 | logger.error("Error processing event %s: %s", event_name, str(e))
97 | return None
98 |
99 | def receive(
100 | self,
101 | event_name: str,
102 | timeout: Optional[None | float] = None,
103 | with_subscription: bool = False,
104 | ) -> Optional[dict]:
105 | if with_subscription:
106 | self.subscribe(event_name=event_name)
107 |
108 | if not (self.manager.is_subscribed(event_name=event_name) or with_subscription):
109 | logger.info("Event %s is not subscribed", event_name)
110 | return None
111 |
112 | if timeout is None:
113 | return self._process_event(event_name=event_name)
114 | else:
115 | return self._process_event(event_name=event_name, timeout=timeout)
116 |
117 | def unsubscribe(
118 | self,
119 | event_name: str,
120 | ) -> None:
121 | logger.info("Unsubscribing from event: %s", event_name)
122 | self.manager.unsubscribe(event_name=event_name)
123 |
--------------------------------------------------------------------------------