mirror of
https://github.com/aykhans/portfolio-blog.git
synced 2025-04-08 15:44:00 +00:00
First Commit
This commit is contained in:
commit
6d45d1c604
3
.dockerignore
Normal file
3
.dockerignore
Normal file
@ -0,0 +1,3 @@
|
||||
media
|
||||
__pycache__
|
||||
.venv
|
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
.venv
|
||||
__pycache__
|
||||
media
|
||||
*.env
|
1
config/app.env.example
Normal file
1
config/app.env.example
Normal file
@ -0,0 +1 @@
|
||||
SECRET_KEY="SECRET_KEY"
|
23
config/nginx.conf
Normal file
23
config/nginx.conf
Normal file
@ -0,0 +1,23 @@
|
||||
upstream app_server{
|
||||
server app:8000;
|
||||
}
|
||||
|
||||
server{
|
||||
listen 80;
|
||||
server_name ui.aykhans.me;
|
||||
|
||||
location / {
|
||||
proxy_pass http://app_server;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Host $host;
|
||||
proxy_redirect off;
|
||||
}
|
||||
|
||||
location /static/ {
|
||||
alias /static/;
|
||||
}
|
||||
|
||||
location /media/ {
|
||||
alias /media/;
|
||||
}
|
||||
}
|
5
config/postgres.env.example
Normal file
5
config/postgres.env.example
Normal file
@ -0,0 +1,5 @@
|
||||
POSTGRES_USER="POSTGRES_USER"
|
||||
POSTGRES_PASSWORD="POSTGRES_PASSWORD"
|
||||
POSTGRES_SERVER="POSTGRES_PASSWORD"
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_DB="POSTGRES_PASSWORD"
|
44
docker-compose.yml
Normal file
44
docker-compose.yml
Normal file
@ -0,0 +1,44 @@
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres
|
||||
ports:
|
||||
- 5432:5432
|
||||
volumes:
|
||||
- dbdata:/var/lib/postgresql/data
|
||||
env_file:
|
||||
- ./config/postgres.env
|
||||
|
||||
app:
|
||||
build: ./src/
|
||||
env_file:
|
||||
- ./config/postgres.env
|
||||
- ./config/app.env
|
||||
ports:
|
||||
- 8000
|
||||
depends_on:
|
||||
- db
|
||||
volumes:
|
||||
- ./src/app:/src/app
|
||||
- static:/src/app/static
|
||||
- media:/src/media
|
||||
command: >
|
||||
bash -c "poetry run alembic upgrade head
|
||||
&& poetry run uvicorn --reload --host=0.0.0.0 --port=8000 app.main:app"
|
||||
|
||||
nginx:
|
||||
image: nginx
|
||||
ports:
|
||||
- 80:80
|
||||
volumes:
|
||||
- ./config/nginx.conf:/etc/nginx/conf.d/default.conf
|
||||
- static:/static
|
||||
- media:/media
|
||||
depends_on:
|
||||
- app
|
||||
|
||||
volumes:
|
||||
dbdata:
|
||||
static:
|
||||
media:
|
15
src/Dockerfile
Normal file
15
src/Dockerfile
Normal file
@ -0,0 +1,15 @@
|
||||
FROM python:3.10-bullseye
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
COPY ./pyproject.toml ./poetry.lock /src/
|
||||
|
||||
RUN pip install poetry
|
||||
|
||||
RUN poetry install
|
||||
|
||||
COPY . /src/
|
||||
|
||||
# RUN poetry run alembic upgrade head
|
||||
|
||||
# CMD ["poetry", "run", "uvicorn", "--reload", "--host=0.0.0.0", "--port=8000", "app.main:app"]
|
114
src/alembic.ini
Normal file
114
src/alembic.ini
Normal file
@ -0,0 +1,114 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts
|
||||
script_location = migrations
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory.
|
||||
prepend_sys_path = .
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the python-dateutil library that can be
|
||||
# installed by adding `alembic[tz]` to the pip requirements
|
||||
# string value is passed to dateutil.tz.gettz()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the
|
||||
# "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to migrations/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "version_path_separator" below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions
|
||||
|
||||
# version path separator; As mentioned above, this is the character used to split
|
||||
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
|
||||
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
|
||||
# Valid values for version_path_separator are:
|
||||
#
|
||||
# version_path_separator = :
|
||||
# version_path_separator = ;
|
||||
# version_path_separator = space
|
||||
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
; sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
|
||||
# hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = %(here)s/.venv/bin/ruff
|
||||
# ruff.options = --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
0
src/app/core/__init__.py
Normal file
0
src/app/core/__init__.py
Normal file
40
src/app/core/config.py
Normal file
40
src/app/core/config.py
Normal file
@ -0,0 +1,40 @@
|
||||
from pydantic import PostgresDsn
|
||||
from pydantic_settings import BaseSettings
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
PROJECT_NAME: str = 'FastAPI Portfolio & Blog'
|
||||
|
||||
MAIN_PATH: Path = Path(__file__).resolve().parent.parent.parent # path to src folder
|
||||
APP_PATH: Path = MAIN_PATH / 'app' # path to app folder
|
||||
MEDIA_FOLDER: Path = Path('media') # name of media folder
|
||||
MEDIA_PATH: Path = MAIN_PATH / MEDIA_FOLDER # path to media folder
|
||||
|
||||
FILE_FOLDERS: dict[str, Path] = {
|
||||
'post_images': Path('post_images'),
|
||||
}
|
||||
|
||||
SECRET_KEY: str
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 43200 # 30 days
|
||||
|
||||
POSTGRES_USER: str
|
||||
POSTGRES_PASSWORD: str
|
||||
POSTGRES_SERVER: str
|
||||
POSTGRES_PORT: int = 5432
|
||||
POSTGRES_DB: str
|
||||
|
||||
def get_postgres_dsn(self, _async: bool=False) -> PostgresDsn:
|
||||
scheme = 'postgresql+asyncpg' if _async else 'postgresql'
|
||||
|
||||
return PostgresDsn.build(
|
||||
scheme=scheme,
|
||||
username=self.POSTGRES_USER,
|
||||
password=self.POSTGRES_PASSWORD,
|
||||
host=self.POSTGRES_SERVER,
|
||||
port=self.POSTGRES_PORT,
|
||||
path=self.POSTGRES_DB
|
||||
)
|
||||
|
||||
|
||||
settings = Settings()
|
39
src/app/core/security.py
Normal file
39
src/app/core/security.py
Normal file
@ -0,0 +1,39 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Union
|
||||
|
||||
from jose import jwt
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
ALGORITHM = "HS256"
|
||||
|
||||
|
||||
def create_access_token(
|
||||
subject: Union[str, Any],
|
||||
expires_delta: timedelta = None
|
||||
) -> str:
|
||||
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(
|
||||
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
|
||||
)
|
||||
|
||||
to_encode = {"exp": expire, "sub": str(subject)}
|
||||
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
2
src/app/crud/__init__.py
Normal file
2
src/app/crud/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
from .crud_user import user
|
||||
from .crud_post import post
|
82
src/app/crud/base.py
Normal file
82
src/app/crud/base.py
Normal file
@ -0,0 +1,82 @@
|
||||
from typing import Any, Dict, Generic, List, Optional, Type, TypeVar, Union
|
||||
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.future import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.base_class import Base
|
||||
|
||||
ModelType = TypeVar("ModelType", bound=Base)
|
||||
CreateSchemaType = TypeVar("CreateSchemaType", bound=BaseModel)
|
||||
UpdateSchemaType = TypeVar("UpdateSchemaType", bound=BaseModel)
|
||||
|
||||
|
||||
class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]):
|
||||
def __init__(self, model: Type[ModelType]):
|
||||
"""
|
||||
CRUD object with default methods to Create, Read, Update, Delete (CRUD).
|
||||
|
||||
**Parameters**
|
||||
|
||||
* `model`: A SQLAlchemy model class
|
||||
* `schema`: A Pydantic model (schema) class
|
||||
"""
|
||||
self.model = model
|
||||
|
||||
async def get_by_id(self, db: Session, id: Any) -> Optional[ModelType]:
|
||||
q = select(self.model).where(self.model.id == id)
|
||||
obj = await db.execute(q)
|
||||
return obj.scalar_one_or_none()
|
||||
|
||||
async def get_multi(
|
||||
self, db: Session, *, skip: int = 0, limit: int = 100
|
||||
) -> List[ModelType]:
|
||||
|
||||
q = select(self.model).offset(skip).limit(limit).order_by(self.model.id.desc())
|
||||
obj = await db.execute(q)
|
||||
return obj.scalars()
|
||||
|
||||
async def create(self, db: Session, *, obj_in: CreateSchemaType) -> ModelType:
|
||||
obj_in_data = jsonable_encoder(obj_in)
|
||||
db_obj = self.model(**obj_in_data) # type: ignore
|
||||
db.add(db_obj)
|
||||
await db.commit()
|
||||
await db.refresh(db_obj)
|
||||
|
||||
return db_obj
|
||||
|
||||
async def update(
|
||||
self,
|
||||
db: Session,
|
||||
*,
|
||||
db_obj: ModelType,
|
||||
obj_in: Union[UpdateSchemaType, Dict[str, Any]]
|
||||
) -> ModelType:
|
||||
|
||||
obj_data = jsonable_encoder(db_obj)
|
||||
|
||||
if isinstance(obj_in, dict):
|
||||
update_data = obj_in
|
||||
|
||||
else:
|
||||
update_data = obj_in.model_dump(exclude_unset=True)
|
||||
|
||||
for field in obj_data:
|
||||
if field in update_data:
|
||||
setattr(db_obj, field, update_data[field])
|
||||
|
||||
db.add(db_obj)
|
||||
await db.commit()
|
||||
await db.refresh(db_obj)
|
||||
|
||||
return db_obj
|
||||
|
||||
async def remove(self, db: Session, *, id: int) -> ModelType:
|
||||
q = select(self.model).where(self.model.id == id)
|
||||
obj = await db.execute(q)
|
||||
obj = obj.scalar_one()
|
||||
await db.delete(obj)
|
||||
await db.commit()
|
||||
|
||||
return obj
|
47
src/app/crud/crud_post.py
Normal file
47
src/app/crud/crud_post.py
Normal file
@ -0,0 +1,47 @@
|
||||
from typing import List
|
||||
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.future import select
|
||||
|
||||
from app.crud.base import CRUDBase
|
||||
from app.models.post import Post
|
||||
from app.schemas.post import (
|
||||
PostCreate,
|
||||
PostUpdate
|
||||
)
|
||||
|
||||
|
||||
class CRUDPost(CRUDBase[Post, PostCreate, PostUpdate]):
|
||||
async def create_with_owner(
|
||||
self, db: Session, *, obj_in: PostCreate, owner_id: int
|
||||
) -> Post:
|
||||
|
||||
obj_in_data = jsonable_encoder(obj_in)
|
||||
db_obj = self.model(**obj_in_data, owner_id=owner_id)
|
||||
db.add(db_obj)
|
||||
await db.commit()
|
||||
await db.refresh(db_obj)
|
||||
|
||||
return db_obj
|
||||
|
||||
async def get_multi_by_owner(
|
||||
self, db: Session, *, owner_id: int, skip: int = 0, limit: int = 100
|
||||
) -> List[Post]:
|
||||
|
||||
q = (
|
||||
select(self.model)
|
||||
.where(self.model.owner_id == owner_id)
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
objs = await db.execute(q)
|
||||
|
||||
return objs.scalars()
|
||||
|
||||
def create(self):
|
||||
raise DeprecationWarning("Use create_with_owner instead")
|
||||
|
||||
post = CRUDPost(Post)
|
66
src/app/crud/crud_user.py
Normal file
66
src/app/crud/crud_user.py
Normal file
@ -0,0 +1,66 @@
|
||||
from typing import Any, Dict, Optional, Union
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.future import select
|
||||
|
||||
from app.core.security import get_password_hash, verify_password
|
||||
from app.crud.base import CRUDBase
|
||||
from app.models.user import User
|
||||
from app.schemas.user import UserCreate, UserUpdate
|
||||
|
||||
|
||||
class CRUDUser(CRUDBase[User, UserCreate, UserUpdate]):
|
||||
async def get_by_email(self, db: Session, *, email: str) -> Optional[User]:
|
||||
q = select(self.model).where(self.model.email == email)
|
||||
obj = await db.execute(q)
|
||||
return obj.scalar_one_or_none()
|
||||
|
||||
async def create(self, db: Session, *, obj_in: UserCreate) -> User:
|
||||
db_obj = User(
|
||||
email=obj_in.email,
|
||||
hashed_password=get_password_hash(obj_in.password),
|
||||
username=obj_in.username,
|
||||
is_superuser=obj_in.is_superuser,
|
||||
)
|
||||
|
||||
db.add(db_obj)
|
||||
await db.commit()
|
||||
await db.refresh(db_obj)
|
||||
|
||||
return db_obj
|
||||
|
||||
async def update(
|
||||
self, db: Session, *, db_obj: User, obj_in: Union[UserUpdate, Dict[str, Any]]
|
||||
) -> User:
|
||||
|
||||
if isinstance(obj_in, dict):
|
||||
update_data = obj_in
|
||||
|
||||
else:
|
||||
update_data = obj_in.model_dump(exclude_unset=True)
|
||||
|
||||
if update_data.get("password"):
|
||||
hashed_password = get_password_hash(update_data["password"])
|
||||
del update_data["password"]
|
||||
update_data["hashed_password"] = hashed_password
|
||||
|
||||
return await super().update(db, db_obj=db_obj, obj_in=update_data)
|
||||
|
||||
async def authenticate(self, db: Session, *, email: str, password: str) -> Optional[User]:
|
||||
user = await self.get_by_email(db, email=email)
|
||||
|
||||
if user is None:
|
||||
return None
|
||||
|
||||
if not verify_password(password, user.hashed_password):
|
||||
return None
|
||||
|
||||
return user
|
||||
|
||||
async def is_active(self, user: User) -> bool:
|
||||
return user.is_active
|
||||
|
||||
async def is_superuser(self, user: User) -> bool:
|
||||
return user.is_superuser
|
||||
|
||||
user = CRUDUser(User)
|
0
src/app/db/__init__.py
Normal file
0
src/app/db/__init__.py
Normal file
1
src/app/db/base.py
Normal file
1
src/app/db/base.py
Normal file
@ -0,0 +1 @@
|
||||
from app.db.base_class import Base
|
19
src/app/db/base_class.py
Normal file
19
src/app/db/base_class.py
Normal file
@ -0,0 +1,19 @@
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.declarative import as_declarative, declared_attr
|
||||
from sqlalchemy import DateTime, Column
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
|
||||
@as_declarative()
|
||||
class Base:
|
||||
__name__: str
|
||||
|
||||
id: Any
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# Generate __tablename__ automatically
|
||||
@declared_attr
|
||||
def __tablename__(cls) -> str:
|
||||
return cls.__name__.lower()
|
37
src/app/db/session.py
Normal file
37
src/app/db/session.py
Normal file
@ -0,0 +1,37 @@
|
||||
from sqlalchemy import (
|
||||
create_engine,
|
||||
Engine
|
||||
)
|
||||
from sqlalchemy.orm import (
|
||||
sessionmaker,
|
||||
Session
|
||||
)
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncEngine,
|
||||
create_async_engine,
|
||||
AsyncSession
|
||||
)
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
engine: Engine = create_engine(
|
||||
str(settings.get_postgres_dsn()),
|
||||
pool_pre_ping=True
|
||||
)
|
||||
SessionLocal: Session = sessionmaker(
|
||||
autocommit=False,
|
||||
autoflush=False,
|
||||
bind=engine
|
||||
)
|
||||
|
||||
async_engine: AsyncEngine = create_async_engine(
|
||||
str(settings.get_postgres_dsn(_async=True)),
|
||||
future=True
|
||||
)
|
||||
AsyncSessionLocal: AsyncSession = sessionmaker(
|
||||
autocommit=False,
|
||||
autoflush=False,
|
||||
bind=async_engine,
|
||||
class_=AsyncSession
|
||||
)
|
145
src/app/main.py
Normal file
145
src/app/main.py
Normal file
@ -0,0 +1,145 @@
|
||||
import io
|
||||
from fastapi import Body, Depends, FastAPI, HTTPException, Request, Form, UploadFile, File, status
|
||||
from fastapi.exception_handlers import request_validation_exception_handler
|
||||
from pydantic import BaseModel, ValidationError, field_validator
|
||||
from app import crud
|
||||
from app.core import security
|
||||
from app.core.config import settings
|
||||
from app.schemas import JWTToken
|
||||
from app.schemas.login import LoginForm
|
||||
|
||||
from app.schemas.post import Post, PostCreate, PostUpdate
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.schemas.user import User, UserCreate
|
||||
from fastapi.responses import HTMLResponse
|
||||
from app.views.depends import get_async_db, handle_image
|
||||
|
||||
from typing import Annotated, Any
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from PIL import Image
|
||||
import aiofiles
|
||||
from pathlib import Path
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from datetime import timedelta
|
||||
from app.views.router import main_router
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.PROJECT_NAME
|
||||
)
|
||||
|
||||
# app.mount(
|
||||
# '/static',
|
||||
# StaticFiles(directory=settings.APP_PATH / 'static'),
|
||||
# name='static'
|
||||
# )
|
||||
|
||||
app.include_router(main_router)
|
||||
|
||||
# templates = Jinja2Templates(directory=main_path / 'templates')
|
||||
|
||||
|
||||
|
||||
# @app.get("/", response_class=HTMLResponse)
|
||||
# async def read_item(request: Request):
|
||||
# return templates.TemplateResponse("test.html", {"request": request})
|
||||
|
||||
# from pydantic import BaseModel
|
||||
|
||||
|
||||
# class User(BaseModel):
|
||||
# username: str
|
||||
# password: str
|
||||
|
||||
# @app.post("/", response_class=HTMLResponse)
|
||||
# async def read_item(request: Request, user: User):
|
||||
# print(user)
|
||||
# return templates.TemplateResponse("index.html", {"request": request})
|
||||
|
||||
# @app.get("/t", response_class=HTMLResponse)
|
||||
# async def read_item(request: Request):
|
||||
# return templates.TemplateResponse("index.html", {"request": request})
|
||||
|
||||
|
||||
@app.exception_handler(ValidationError)
|
||||
async def validation_exception_handler(request, exc):
|
||||
return await request_validation_exception_handler(request, exc)
|
||||
|
||||
|
||||
# @app.post("/login", response_model=JWTToken)
|
||||
# async def login(
|
||||
# db: AsyncSession = Depends(get_async_db),
|
||||
# form_data: LoginForm = Depends()
|
||||
# ) -> Any:
|
||||
|
||||
# user = await crud.user.authenticate(
|
||||
# db, email=form_data.email, password=form_data.password
|
||||
# )
|
||||
|
||||
# if user is None:
|
||||
# raise HTTPException(status_code=400, detail="Incorrect email or password")
|
||||
|
||||
# elif user.is_active is False:
|
||||
# raise HTTPException(status_code=400, detail="Inactive user")
|
||||
|
||||
# access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
|
||||
# return {
|
||||
# "access_token": security.create_access_token(
|
||||
# user.email,
|
||||
# expires_delta=access_token_expires
|
||||
# ),
|
||||
# "token_type": "bearer",
|
||||
# }
|
||||
|
||||
|
||||
# @app.get('/alma')
|
||||
# async def test(
|
||||
# *,
|
||||
# request: Request,
|
||||
# db: AsyncSession = Depends(get_async_db),
|
||||
# # title: str = Form(...),
|
||||
# # text: str = Form(...),
|
||||
# # image: str = Depends(handle_image),
|
||||
# ):
|
||||
|
||||
# # post = PostCreate(title=title, text=text, image_path=image)
|
||||
# # post = await crud.post.create_with_owner(db, obj_in=post, owner_id=1)
|
||||
|
||||
# # async with aiofiles.open(settings.MEDIA_PATH / settings.FILE_FOLDERS['post_images'] / image.filename, 'wb') as out_file:
|
||||
# # content = await image.read()
|
||||
# # await out_file.write(content)
|
||||
|
||||
# # post = await crud.post.remove(db, id=34)
|
||||
# # post = await crud.post.get_by_id(db, id=33)
|
||||
|
||||
# # if post is None:
|
||||
# # raise HTTPException(status_code=404, detail="Post not found")
|
||||
|
||||
# # post = await crud.post.update(db, db_obj=post, obj_in={'text': text})
|
||||
|
||||
# # posts = await crud.post.get_multi(db)
|
||||
|
||||
# # posts = await crud.post.get_multi_by_owner(db, owner_id=1)
|
||||
|
||||
# user = await crud.user.get_by_email(db, email='aykhan.shahs0@gmail.com')
|
||||
# print(type(user))
|
||||
# # if user is not None:
|
||||
# # raise HTTPException(status_code=400, detail="Email already registered")
|
||||
|
||||
# # user = await crud.user.create(db, obj_in=UserCreate(email='aykhan.shahs0@gmail.com', password='alma'))
|
||||
|
||||
# # user = await crud.user.update(db, db_obj=user, obj_in={'password': 'alma'})
|
||||
|
||||
# # user = await crud.user.authenticate(db, email='aykhan.shahs1@gmail.com', password='alma')
|
||||
|
||||
# # if user is None:
|
||||
# # raise HTTPException(status_code=400, detail="Incorrect email or password")
|
||||
|
||||
# # user = await crud.user.remove(db, id=2)
|
||||
|
||||
# return user
|
2
src/app/models/__init__.py
Normal file
2
src/app/models/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
from .post import Post
|
||||
from .user import User
|
69
src/app/models/post.py
Normal file
69
src/app/models/post.py
Normal file
@ -0,0 +1,69 @@
|
||||
from slugify import slugify
|
||||
|
||||
from sqlalchemy.orm.base import NO_VALUE
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.event import (
|
||||
listen,
|
||||
listens_for
|
||||
)
|
||||
from sqlalchemy import (
|
||||
Column,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String
|
||||
)
|
||||
|
||||
from app.db.base_class import Base
|
||||
|
||||
|
||||
class Post(Base):
|
||||
id = Column(Integer(), primary_key=True, index=True)
|
||||
title = Column(String(100), index=True, nullable=False)
|
||||
slug = Column(String(), index=True, nullable=False, unique=True)
|
||||
text = Column(String(), index=True, nullable=False)
|
||||
image_path = Column(String(100), index=True, unique=True, nullable=True)
|
||||
owner_id = Column(Integer(), ForeignKey("user.id"))
|
||||
|
||||
owner = relationship("User", back_populates="posts")
|
||||
|
||||
|
||||
from app.views.depends import get_db
|
||||
from app.utils.file_operations import remove_file
|
||||
from app.core.config import settings
|
||||
|
||||
def generate_slug(target, value, oldvalue, initiator):
|
||||
slug = slugify(value)
|
||||
|
||||
db = next(get_db())
|
||||
|
||||
number = 1
|
||||
temp_slug = slug
|
||||
|
||||
while db.query(Post).filter(Post.slug == temp_slug).first() is not None:
|
||||
temp_slug = f'{slug}-{number}'
|
||||
number += 1
|
||||
|
||||
target.slug = temp_slug
|
||||
|
||||
listen(Post.title, 'set', generate_slug)
|
||||
|
||||
|
||||
def remove_old_image_on_update(target, value, oldvalue, initiator):
|
||||
if oldvalue is not NO_VALUE:
|
||||
remove_file(
|
||||
settings.MEDIA_PATH /
|
||||
settings.FILE_FOLDERS['post_images'] /
|
||||
oldvalue
|
||||
)
|
||||
|
||||
listen(Post.image_path, 'set', remove_old_image_on_update)
|
||||
|
||||
|
||||
@listens_for(Post, 'before_delete')
|
||||
def before_delete_listener(mapper, connection, target):
|
||||
if target.image_path:
|
||||
remove_file(
|
||||
settings.MEDIA_PATH /
|
||||
settings.FILE_FOLDERS['post_images'] /
|
||||
target.image_path
|
||||
)
|
15
src/app/models/user.py
Normal file
15
src/app/models/user.py
Normal file
@ -0,0 +1,15 @@
|
||||
from sqlalchemy import Boolean, Column, Integer, String
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.db.base_class import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
id = Column(Integer(), primary_key=True, index=True)
|
||||
username = Column(String(), index=True)
|
||||
email = Column(String(), unique=True, index=True, nullable=False)
|
||||
hashed_password = Column(String(), nullable=False)
|
||||
is_active = Column(Boolean(), default=True)
|
||||
is_superuser = Column(Boolean(), default=False)
|
||||
|
||||
posts = relationship("Post", back_populates="owner")
|
19
src/app/schemas/__init__.py
Normal file
19
src/app/schemas/__init__.py
Normal file
@ -0,0 +1,19 @@
|
||||
from .post import (
|
||||
Post,
|
||||
PostCreate,
|
||||
PostInDBBase,
|
||||
PostUpdate,
|
||||
PostInTemplate,
|
||||
ListPostInTemplate
|
||||
)
|
||||
from .user import (
|
||||
User,
|
||||
UserCreate,
|
||||
UserInDBBase,
|
||||
UserUpdate
|
||||
)
|
||||
from .login import (
|
||||
JWTToken,
|
||||
JWTTokenPayload,
|
||||
LoginForm
|
||||
)
|
23
src/app/schemas/login.py
Normal file
23
src/app/schemas/login.py
Normal file
@ -0,0 +1,23 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from fastapi import Form
|
||||
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
EmailStr
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LoginForm:
|
||||
email: str = Form(...)
|
||||
password: str = Form(...)
|
||||
|
||||
|
||||
class JWTToken(BaseModel):
|
||||
access_token: str
|
||||
token_type: str
|
||||
|
||||
|
||||
class JWTTokenPayload(BaseModel):
|
||||
sub: Optional[EmailStr] = None
|
61
src/app/schemas/post.py
Normal file
61
src/app/schemas/post.py
Normal file
@ -0,0 +1,61 @@
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
Field,
|
||||
PastDatetime,
|
||||
computed_field,
|
||||
TypeAdapter
|
||||
)
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class PostBase(BaseModel):
|
||||
title: Optional[str] = None
|
||||
text: Optional[str] = None
|
||||
image_path: Optional[str] = None
|
||||
|
||||
|
||||
class PostCreate(PostBase):
|
||||
title: str = Field(max_length=100)
|
||||
text: str
|
||||
image_path: str
|
||||
|
||||
|
||||
class PostUpdate(PostBase):
|
||||
title: Optional[str] = Field(max_length=100)
|
||||
|
||||
|
||||
class PostInTemplate(PostBase):
|
||||
title: str
|
||||
text: str
|
||||
created_at: PastDatetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
ListPostInTemplate = TypeAdapter(list[PostInTemplate])
|
||||
|
||||
|
||||
class PostInDBBase(PostBase):
|
||||
slug: str
|
||||
title: str
|
||||
text: str
|
||||
image_path: str
|
||||
owner_id: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class Post(PostInDBBase):
|
||||
@computed_field
|
||||
@property
|
||||
def image_url(self) -> str:
|
||||
return str(
|
||||
settings.MEDIA_FOLDER /
|
||||
settings.FILE_FOLDERS['post_images'] /
|
||||
self.image_path
|
||||
)
|
34
src/app/schemas/user.py
Normal file
34
src/app/schemas/user.py
Normal file
@ -0,0 +1,34 @@
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
|
||||
class UserBase(BaseModel):
|
||||
email: Optional[EmailStr] = None
|
||||
is_active: Optional[bool] = True
|
||||
is_superuser: bool = False
|
||||
username: Optional[str] = None
|
||||
|
||||
|
||||
class UserCreate(UserBase):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class UserUpdate(UserBase):
|
||||
password: Optional[str] = None
|
||||
|
||||
|
||||
class UserInDBBase(UserBase):
|
||||
id: Optional[int] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class User(UserInDBBase):
|
||||
pass
|
||||
|
||||
|
||||
class UserInDB(UserInDBBase):
|
||||
hashed_password: str
|
14435
src/app/static/bootstrap/css/bootstrap.min.css
vendored
Normal file
14435
src/app/static/bootstrap/css/bootstrap.min.css
vendored
Normal file
File diff suppressed because it is too large
Load Diff
6
src/app/static/bootstrap/js/bootstrap.min.js
vendored
Normal file
6
src/app/static/bootstrap/js/bootstrap.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
src/app/static/fonts/FontAwesome.otf
Normal file
BIN
src/app/static/fonts/FontAwesome.otf
Normal file
Binary file not shown.
4
src/app/static/fonts/font-awesome.min.css
vendored
Normal file
4
src/app/static/fonts/font-awesome.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
src/app/static/fonts/fontawesome-webfont.eot
Normal file
BIN
src/app/static/fonts/fontawesome-webfont.eot
Normal file
Binary file not shown.
2671
src/app/static/fonts/fontawesome-webfont.svg
Normal file
2671
src/app/static/fonts/fontawesome-webfont.svg
Normal file
File diff suppressed because it is too large
Load Diff
After Width: | Height: | Size: 434 KiB |
BIN
src/app/static/fonts/fontawesome-webfont.ttf
Normal file
BIN
src/app/static/fonts/fontawesome-webfont.ttf
Normal file
Binary file not shown.
BIN
src/app/static/fonts/fontawesome-webfont.woff
Normal file
BIN
src/app/static/fonts/fontawesome-webfont.woff
Normal file
Binary file not shown.
BIN
src/app/static/fonts/fontawesome-webfont.woff2
Normal file
BIN
src/app/static/fonts/fontawesome-webfont.woff2
Normal file
Binary file not shown.
BIN
src/app/static/img/bryan-goff-f7YQo-eYHdM-unsplash.jpg
Normal file
BIN
src/app/static/img/bryan-goff-f7YQo-eYHdM-unsplash.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 190 KiB |
BIN
src/app/static/img/shipit.png
Normal file
BIN
src/app/static/img/shipit.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.5 KiB |
36
src/app/static/js/clean-blog.js
Normal file
36
src/app/static/js/clean-blog.js
Normal file
@ -0,0 +1,36 @@
|
||||
(function() {
|
||||
"use strict"; // Start of use strict
|
||||
|
||||
// Show the navbar when the page is scrolled up
|
||||
var MQL = 992;
|
||||
var vw = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0);
|
||||
var mainNav = document.querySelector('#mainNav');
|
||||
|
||||
//primary navigation slide-in effect
|
||||
if (mainNav && vw > MQL) {
|
||||
var headerHeight = mainNav.offsetHeight;
|
||||
var previousTop = window.pageYOffset;
|
||||
|
||||
window.addEventListener('scroll', function() {
|
||||
var currentTop = window.pageYOffset;
|
||||
//check if user is scrolling up
|
||||
if (currentTop < previousTop) {
|
||||
//if scrolling up...
|
||||
if (currentTop > 0 && mainNav.classList.contains('is-fixed')) {
|
||||
mainNav.classList.add('is-visible');
|
||||
} else {
|
||||
mainNav.classList.remove('is-visible', 'is-fixed');
|
||||
}
|
||||
} else if (currentTop > previousTop) {
|
||||
//if scrolling down...
|
||||
mainNav.classList.remove('is-visible');
|
||||
|
||||
if (currentTop > headerHeight && !mainNav.classList.contains('is-fixed')) {
|
||||
mainNav.classList.add('is-fixed');
|
||||
}
|
||||
}
|
||||
previousTop = currentTop;
|
||||
});
|
||||
}
|
||||
|
||||
})(); // End of use strict
|
41
src/app/templates/admin/login.html
Normal file
41
src/app/templates/admin/login.html
Normal file
@ -0,0 +1,41 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Login Example</title>
|
||||
</head>
|
||||
<body>
|
||||
<form id="loginForm">
|
||||
<!-- Your login input fields go here -->
|
||||
<input type="text" name="email" placeholder="email">
|
||||
<input type="password" name="password" placeholder="Password">
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
// Function to handle the form submission
|
||||
function handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
const form = document.getElementById('loginForm');
|
||||
const formData = new FormData(form);
|
||||
|
||||
fetch('/login', {
|
||||
method: 'POST', // Adjust the HTTP method if needed
|
||||
body: formData,
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const accessToken = data.access_token;
|
||||
console.log(accessToken);
|
||||
document.cookie = `access_token=${accessToken}`;
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Login error:', error);
|
||||
});
|
||||
}
|
||||
|
||||
// Add a submit event listener to the form
|
||||
const loginForm = document.getElementById('loginForm');
|
||||
loginForm.addEventListener('submit', handleSubmit);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
55
src/app/templates/base.html
Normal file
55
src/app/templates/base.html
Normal file
@ -0,0 +1,55 @@
|
||||
<!DOCTYPE html>
|
||||
<html data-bs-theme="light" lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
|
||||
<title>{% block title %}{% endblock title %}</title>
|
||||
<link rel="icon" type="image/png" href="static/img/shipit.png">
|
||||
<link rel="stylesheet" href="static/bootstrap/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Open+Sans:300italic,400italic,600italic,700italic,800italic,400,300,600,700,800&display=swap">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Lora:400,700,400italic,700italic&display=swap">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Abril+Fatface&display=swap">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Aclonica&display=swap">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Alatsi&display=swap">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Alfa+Slab+One&display=swap">
|
||||
<link rel="stylesheet" href="static/fonts/font-awesome.min.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg fixed-top navbar-light" id="mainNav">
|
||||
<div class="container"><a class="navbar-brand fw-bolder" href="/#" style="font-family: Lora, serif;font-size: 35px;" data-bs-target="/">#</a><button data-bs-toggle="collapse" data-bs-target="#navbarResponsive" class="navbar-toggler" aria-controls="navbarResponsive" aria-expanded="false" aria-label="Toggle navigation" style="padding: 6px;border-radius: 0px;border-width: 0px;"><i class="fa fa-bars" style="font-size: 27px;"></i></button>
|
||||
<div class="collapse navbar-collapse" id="navbarResponsive">
|
||||
<ul class="navbar-nav ms-auto">
|
||||
<li class="nav-item"><a class="nav-link fs-5" href="/#about">about</a></li>
|
||||
<li class="nav-item"><a class="nav-link fs-5" href="/#contact">contact</a></li>
|
||||
<li class="nav-item"><a class="nav-link fs-5" href="/blog">blog</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{% block content %}{% endblock content %}
|
||||
|
||||
<footer>
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-10 col-lg-8 mx-auto">
|
||||
<ul class="list-inline text-center">
|
||||
<li class="list-inline-item"><a class="link-body-emphasis" href="mailto:aykhan.shahs@gmail.com"><span class="fa-stack fa-lg"><i class="fa fa-circle fa-stack-2x"></i><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" class="fa-stack-1x fa-inverse" style="font-size: 34px;margin-top: 9px;">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.00977 5.83789C3.00977 5.28561 3.45748 4.83789 4.00977 4.83789H20C20.5523 4.83789 21 5.28561 21 5.83789V17.1621C21 18.2667 20.1046 19.1621 19 19.1621H5C3.89543 19.1621 3 18.2667 3 17.1621V6.16211C3 6.11449 3.00333 6.06765 3.00977 6.0218V5.83789ZM5 8.06165V17.1621H19V8.06199L14.1215 12.9405C12.9499 14.1121 11.0504 14.1121 9.87885 12.9405L5 8.06165ZM6.57232 6.80554H17.428L12.7073 11.5263C12.3168 11.9168 11.6836 11.9168 11.2931 11.5263L6.57232 6.80554Z" fill="currentColor"></path>
|
||||
</svg></span></a></li>
|
||||
<li class="list-inline-item"><a class="link-body-emphasis" href="https://www.linkedin.com/in/aykhan-shahsuvarov-59a314187/" target="_blank"><span class="fa-stack fa-lg"><i class="fa fa-circle fa-stack-2x"></i><i class="fa fa-linkedin fa-stack-1x fa-inverse"></i></span></a></li>
|
||||
<li class="list-inline-item"><a class="link-body-emphasis" href="whatsapp://send?abid=+994998998951&text=Hello%2C%20World!/" target="_blank"><span class="fa-stack fa-lg"><i class="fa fa-circle fa-stack-2x"></i><i class="fa fa-whatsapp fa-stack-1x fa-inverse"></i></span></a></li>
|
||||
<li class="list-inline-item"><a class="link-body-emphasis" href="https://github.com/Aykhan-s" target="_blank"><span class="fa-stack fa-lg"><i class="fa fa-circle fa-stack-2x"></i><i class="fa fa-github fa-stack-1x fa-inverse"></i></span></a></li>
|
||||
</ul>
|
||||
<p class="text-muted copyright" style="margin-top: 10px;">Aykhan Shahsuvarov</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<script src="static/bootstrap/js/bootstrap.min.js"></script>
|
||||
<script src="static/js/clean-blog.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
44
src/app/templates/blog.html
Normal file
44
src/app/templates/blog.html
Normal file
@ -0,0 +1,44 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Blog{% endblock title %}
|
||||
|
||||
{% block content %}
|
||||
<header class="masthead" style="background: url("static/img/bryan-goff-f7YQo-eYHdM-unsplash.jpg");">
|
||||
<div class="overlay"></div>
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-10 col-lg-8 mx-auto position-relative">
|
||||
<div class="site-heading">
|
||||
<h1><strong>Blog</strong></h1><span class="subheading">you won't find the meaning of life below</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<article>
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-10 col-lg-8 mx-auto">
|
||||
{% for post in posts %}
|
||||
<div class="post-preview">
|
||||
<a href="#">
|
||||
<h2 class="post-title">{{post.title}}</h2>
|
||||
<h3 class="post-subtitle">{{post.text}}</h3>
|
||||
</a>
|
||||
<p class="post-meta">Posted at <a href="#">{{post.created_at}}</a></p>
|
||||
</div>
|
||||
<hr>
|
||||
{% endfor %}
|
||||
<div class="clearfix">
|
||||
<button class="btn btn-primary float-end" type="button" style="margin-left: 2rem; background: rgb(0, 133, 161);">
|
||||
<a href="/blog?skip={{skip}}" style="text-decoration: none; color: inherit;">Older Posts ⇒</a>
|
||||
</button>
|
||||
<button class="btn btn-primary float-end" type="button" style="background: rgb(0, 133, 161);">
|
||||
<a href="/blog?skip={{skip}}&new=true" style="text-decoration: none; color: inherit;">⇐ Newer Posts</a>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{% endblock content %}
|
1
src/app/templates/components/navbar.html
Normal file
1
src/app/templates/components/navbar.html
Normal file
@ -0,0 +1 @@
|
||||
<h1>Navbar</h1>
|
79
src/app/templates/index.html
Normal file
79
src/app/templates/index.html
Normal file
@ -0,0 +1,79 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Just a Page{% endblock title %}
|
||||
|
||||
{% block content %}
|
||||
<header class="masthead" style="background: url("static/img/bryan-goff-f7YQo-eYHdM-unsplash.jpg");">
|
||||
<div class="overlay"></div>
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-10 col-lg-8 mx-auto position-relative">
|
||||
<div class="site-heading">
|
||||
<h1><strong>Just a Page</strong></h1><span class="subheading">Actually this is the home page</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<section id="about" class="about" style="margin-top: 80px;">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-10 col-lg-8 mx-auto">
|
||||
<h1><strong>¿WhO</strong> aM I</h1>
|
||||
<p>I am 20 years old and I am studying computer science in the 4th grade of Azerbaijan Technical University.</p>
|
||||
<p>I have been learning and using the Python programming language for over 2 years. So far I have used python in many projects mainly for developing web based applications.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" style="margin-top: 50px;">
|
||||
<div class="col-md-10 col-lg-8 mx-auto">
|
||||
<h1 style="margin-bottom: 12px;"><strong>Tools I usually use</strong></h1>
|
||||
<div class="row" style="margin-right: -6px;padding-right: 116px;margin-bottom: 35px;">
|
||||
<div class="col-xxl-2" style="margin-top: 10px;"><a href="https://www.python.org/" target="_blank" style="margin-right: 0;margin-top: 0;"><img src="https://img.shields.io/badge/python-3670A0?style=for-the-badge&logo=python&logoColor=ffdd54" alt="django" width="116" height="37"></a></div>
|
||||
<div class="col-xxl-2" style="margin-top: 10px;"><a href="https://www.djangoproject.com/" target="_blank" style="margin-right: 0;margin-top: 0;"><img src="https://img.shields.io/badge/django-%23092E20.svg?style=for-the-badge&logo=django&logoColor=white" alt="django" width="116" height="37"></a></div>
|
||||
<div class="col" style="margin-top: 10px;"><a href="https://www.django-rest-framework.org/" target="_blank" style="margin-right: 0;margin-top: 0;"><img src="https://img.shields.io/badge/DJANGO-REST-ff1709?style=for-the-badge&logo=django&logoColor=white&color=ff1709&labelColor=gray" alt="django-rest framework" width="161" height="37"></a></div>
|
||||
<div class="col" style="margin-top: 10px;"><a href="https://www.postman.com/" target="_blank" style="margin-right: 0;margin-top: 0;"><img src="https://img.shields.io/badge/Postman-FF6C37?style=for-the-badge&logo=postman&logoColor=white" alt="django" width="127" height="37"></a></div>
|
||||
<div class="col" style="margin-top: 10px;"><a href="https://selenium-python.readthedocs.io/" target="_blank" style="margin-right: 0;margin-top: 0;"><img src="https://img.shields.io/badge/-selenium-%43B02A?style=for-the-badge&logo=selenium&logoColor=white" alt="selenium" width="132" height="37"></a></div>
|
||||
</div>
|
||||
<div class="row" style="margin-right: -6px;padding-right: 116px;margin-bottom: 35px;">
|
||||
<div class="col-xxl-2" style="margin-top: 10px;"><a href="https://pandas.pydata.org/" target="_blank" style="margin-right: 0;margin-top: 0;"><img src="https://img.shields.io/badge/pandas-%23150458.svg?style=for-the-badge&logo=pandas&logoColor=white" alt="pandas" width="116" height="37"></a></div>
|
||||
<div class="col-xxl-2" style="margin-top: 10px;"><a href="https://numpy.org/" target="_blank" style="margin-right: 0;margin-top: 0;"><img src="https://img.shields.io/badge/numpy-%23013243.svg?style=for-the-badge&logo=numpy&logoColor=white" alt="numpy" alt="numpy" width="119" height="37"></a></div>
|
||||
<div class="col" style="margin-top: 10px;"><a href="https://streamlit.io/" target="_blank" style="margin-right: 0;margin-top: 0;"><img src="https://img.shields.io/badge/Streamlit-%23D00000.svg?style=for-the-badge&logoColor=white" alt="streamlit" width="116" height="37"></a></div>
|
||||
</div>
|
||||
<div class="row" style="margin-right: -6px;padding-right: 116px;">
|
||||
<div class="col-xxl-2" style="margin-top: 10px;"><a href="https://www.docker.com/" target="_blank" style="margin-right: 0;margin-top: 0;"><img src="https://img.shields.io/badge/docker-%230db7ed.svg?style=for-the-badge&logo=docker&logoColor=white" alt="docker" width="116" height="37"></a></div>
|
||||
<div class="col-xxl-2" style="margin-top: 10px;"><a href="https://www.postgresql.org/" target="_blank" style="margin-right: 0;margin-top: 0;"><img src="https://img.shields.io/badge/postgres-%23316192.svg?style=for-the-badge&logo=postgresql&logoColor=white" alt="postgres" width="121" height="37"></a></div>
|
||||
<div class="col-xxl-2" style="margin-top: 10px;"><a href="https://www.nginx.com/" target="_blank" style="margin-right: 0;margin-top: 0;"><img src="https://img.shields.io/badge/nginx-%23009639.svg?style=for-the-badge&logo=nginx&logoColor=white" alt="nginx" width="116" height="37"></a></div>
|
||||
<div class="col-xxl-2" style="margin-top: 10px;"><a href="https://aws.amazon.com/" target="_blank" style="margin-right: 0;margin-top: 0;"><img src="https://img.shields.io/badge/AWS-%23FF9900.svg?style=for-the-badge&logo=amazon-aws&logoColor=white" alt="AWS" width="105" height="37"></a></div>
|
||||
<div class="col" style="margin-top: 10px;"><a href="https://ubuntu.com/" target="_blank" style="margin-right: 0;margin-top: 0;"><img src="https://img.shields.io/badge/Ubuntu-E95420?style=for-the-badge&logo=ubuntu&logoColor=white" alt="django" width="126" height="37"></a></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section id="contact" class="contact" style="margin-top: 150px;margin-bottom: 20px;">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-10 col-lg-8 mx-auto">
|
||||
<h1>Contact me</h1>
|
||||
<p>Want to get in touch? Fill out the form below to send me a message and I will get back to you as soon as possible!</p>
|
||||
<form id="contactForm" name="sentMessage">
|
||||
<div class="control-group">
|
||||
<div class="form-floating controls mb-3"><input class="form-control" type="text" id="name" required="" placeholder="Name"><label class="form-label" for="name">Name</label><small class="form-text text-danger help-block"></small></div>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<div class="form-floating controls mb-3"><input class="form-control" type="email" id="email" required="" placeholder="Email Address"><label class="form-label">Email Address</label><small class="form-text text-danger help-block"></small></div>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<div class="form-floating controls mb-3"><input class="form-control" type="tel" id="phone" required="" placeholder="Phone Number"><label class="form-label">Phone Number</label><small class="form-text text-danger help-block"></small></div>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<div class="form-floating controls mb-3"><textarea class="form-control" id="message" data-validation-required-message="Please enter a message." required="" placeholder="Message" style="height: 150px;"></textarea><label class="form-label">Message</label><small class="form-text text-danger help-block"></small></div>
|
||||
</div>
|
||||
<div id="success"></div>
|
||||
<div class="mb-3"><button class="btn btn-primary" id="sendMessageButton" type="submit">Send</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock content %}
|
45
src/app/templates/post.html
Normal file
45
src/app/templates/post.html
Normal file
@ -0,0 +1,45 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Blog Post{% endblock title %}
|
||||
|
||||
{% block content %}
|
||||
<header class="masthead" style="background: url("static/img/bryan-goff-f7YQo-eYHdM-unsplash.jpg");">
|
||||
<div class="overlay"></div>
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-10 col-lg-8 mx-auto position-relative">
|
||||
<div class="site-heading">
|
||||
<h1><strong>Title</strong></h1><span class="subheading">Date</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<article>
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-10 col-lg-8 mx-auto">
|
||||
<p>Never in all their history have men been able truly to conceive of the world as one: a single sphere, a globe, having the qualities of a globe, a round earth in which all the directions eventually meet, in which there is no center because every point, or none, is center — an equal earth which all men occupy as equals. The airman's earth, if free men make it, will be truly round: a globe in practice, not in theory.</p>
|
||||
<p>Science cuts two ways, of course; its products can be used for both good and evil. But there's no turning back from science. The early warnings about technological dangers also come from science.</p>
|
||||
<p>What was most significant about the lunar voyage was not that man set foot on the Moon but that they set eye on the earth.</p>
|
||||
<p>A Chinese tale tells of some men sent to harm a young girl who, upon seeing her beauty, become her protectors rather than her violators. That's how I felt seeing the Earth for the first time. I could not help but love and cherish her.</p>
|
||||
<p>For those who have seen the Earth from space, and for the hundreds and perhaps thousands more who will, the experience most certainly changes your perspective. The things that we share in our world are far more valuable than those which divide us.</p>
|
||||
<h2 class="section-heading">Heading</h2>
|
||||
<p>There can be no thought of finishing for ‘aiming for the stars.’ Both figuratively and literally, it is a task to occupy the generations. And no matter how much progress one makes, there is always the thrill of just beginning.</p>
|
||||
<p>There can be no thought of finishing for ‘aiming for the stars.’ Both figuratively and literally, it is a task to occupy the generations. And no matter how much progress one makes, there is always the thrill of just beginning.</p>
|
||||
<figure>
|
||||
<blockquote class="blockquote">
|
||||
<p class="mb-0">The dreams of yesterday are the hopes of today and the reality of tomorrow. Science has not yet mastered prophecy. We predict too much for the next year and yet far too little for the next ten.</p>
|
||||
</blockquote>
|
||||
</figure>
|
||||
<p>Spaceflights cannot be stopped. This is not the work of any one man or even a group of men. It is a historical process which mankind is carrying out in accordance with the natural laws of human development.</p>
|
||||
<h2 class="section-heading">Reaching for the Stars</h2>
|
||||
<p>As we got further and further away, it [the Earth] diminished in size. Finally it shrank to the size of a marble, the most beautiful you can imagine. That beautiful, warm, living object looked so fragile, so delicate, that if you touched it with a finger it would crumble and fall apart. Seeing this has to change a man.</p><a href="#"><img class="img-fluid" src="static/img/post-sample-image.jpg"></a><span class="text-muted caption">To go places and do things that have never been done before – that’s what living is all about.</span>
|
||||
<p>Space, the final frontier. These are the voyages of the Starship Enterprise. Its five-year mission: to explore strange new worlds, to seek out new life and new civilizations, to boldly go where no man has gone before.</p>
|
||||
<p>As I stand out here in the wonders of the unknown at Hadley, I sort of realize there’s a fundamental truth to our nature, Man must explore, and this is exploration at its greatest.</p>
|
||||
<p><span>Placeholder text by </span><a href="http://spaceipsum.com">Space Ipsum</a><span> Photographs by </span><a href="https://www.flickr.com/photos/nasacommons/">NASA on The Commons</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{% endblock content %}
|
0
src/app/utils/__init__.py
Normal file
0
src/app/utils/__init__.py
Normal file
0
src/app/utils/create_user.py
Normal file
0
src/app/utils/create_user.py
Normal file
12
src/app/utils/file_operations.py
Normal file
12
src/app/utils/file_operations.py
Normal file
@ -0,0 +1,12 @@
|
||||
from pathlib import Path
|
||||
from os import remove
|
||||
|
||||
|
||||
async def mkdir_if_not_exists(path: Path) -> None:
|
||||
if not path.exists():
|
||||
path.mkdir(parents=True)
|
||||
|
||||
|
||||
def remove_file(file_path: str) -> None:
|
||||
try: remove(file_path)
|
||||
except FileNotFoundError: ...
|
25
src/app/utils/image_operations.py
Normal file
25
src/app/utils/image_operations.py
Normal file
@ -0,0 +1,25 @@
|
||||
from pathlib import Path
|
||||
from PIL import Image
|
||||
|
||||
from app.utils.file_operations import mkdir_if_not_exists
|
||||
|
||||
|
||||
async def generate_unique_image_name(
|
||||
path: Path,
|
||||
image_name: Path | str,
|
||||
image_format: str
|
||||
) -> Path | str:
|
||||
|
||||
number = 1
|
||||
temp_image_name = image_name
|
||||
|
||||
while (path / temp_image_name).exists():
|
||||
temp_image_name = f'{image_name}-{number}.{image_format}'
|
||||
number += 1
|
||||
|
||||
return temp_image_name
|
||||
|
||||
|
||||
async def save_image(image: Image, image_path: Path) -> None:
|
||||
await mkdir_if_not_exists(image_path.parent)
|
||||
image.save(image_path)
|
0
src/app/views/__init__.py
Normal file
0
src/app/views/__init__.py
Normal file
133
src/app/views/depends.py
Normal file
133
src/app/views/depends.py
Normal file
@ -0,0 +1,133 @@
|
||||
import io
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Generator
|
||||
from PIL import Image
|
||||
from jose import jwt
|
||||
from pydantic import ValidationError
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from fastapi import (
|
||||
Cookie,
|
||||
Depends,
|
||||
File,
|
||||
HTTPException,
|
||||
UploadFile,
|
||||
status
|
||||
)
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
|
||||
from app.models.user import User as UserModel
|
||||
from app.core import security
|
||||
from app.core.config import settings
|
||||
from app.db.session import SessionLocal, AsyncSessionLocal
|
||||
from app import crud
|
||||
from app import schemas
|
||||
from app.utils.image_operations import (
|
||||
generate_unique_image_name,
|
||||
save_image
|
||||
)
|
||||
|
||||
|
||||
reusable_oauth2 = OAuth2PasswordBearer(tokenUrl='/login')
|
||||
|
||||
def get_db() -> Generator:
|
||||
try:
|
||||
db = SessionLocal()
|
||||
yield db
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
async def get_async_db() -> Generator:
|
||||
try:
|
||||
async with AsyncSessionLocal() as async_db:
|
||||
yield async_db
|
||||
|
||||
finally:
|
||||
await async_db.close()
|
||||
|
||||
|
||||
async def get_access_token_from_cookie(access_token: Annotated[str, Cookie()]):
|
||||
return access_token
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
db: AsyncSession = Depends(get_async_db), token: str = Depends(get_access_token_from_cookie)
|
||||
) -> UserModel:
|
||||
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token, settings.SECRET_KEY, algorithms=[security.ALGORITHM]
|
||||
)
|
||||
token_data = schemas.JWTTokenPayload(**payload)
|
||||
|
||||
except (jwt.JWTError, ValidationError):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Could not validate credentials",
|
||||
)
|
||||
|
||||
if token_data.sub is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
user = await crud.user.get_by_email(db, email=token_data.sub)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_active_user(
|
||||
current_user: UserModel = Depends(get_current_user),
|
||||
) -> UserModel:
|
||||
|
||||
if current_user.is_active is False:
|
||||
raise HTTPException(status_code=400, detail="Inactive user")
|
||||
return current_user
|
||||
|
||||
|
||||
async def get_current_active_superuser(
|
||||
current_user: UserModel = Depends(get_current_active_user),
|
||||
) -> UserModel:
|
||||
|
||||
if current_user.is_superuser is False:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="The user doesn't have enough privileges"
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
async def handle_image(image: UploadFile = File(...)) -> str:
|
||||
try:
|
||||
pil_image = Image.open(io.BytesIO(image.file.read()))
|
||||
|
||||
if pil_image.format.lower() not in ['png', 'jpg', 'jpeg']:
|
||||
raise ValueError('Invalid image format')
|
||||
|
||||
unique_image_name = await generate_unique_image_name(
|
||||
path = settings.MEDIA_PATH / settings.FILE_FOLDERS['post_images'],
|
||||
image_name = image.filename,
|
||||
image_format = pil_image.format.lower()
|
||||
)
|
||||
|
||||
await save_image(
|
||||
image = pil_image,
|
||||
image_path = settings.MEDIA_PATH /
|
||||
settings.FILE_FOLDERS['post_images'] /
|
||||
unique_image_name
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail='Invalid image'
|
||||
)
|
||||
|
||||
finally:
|
||||
try:
|
||||
pil_image.close()
|
||||
|
||||
except: ...
|
||||
return str(unique_image_name)
|
10
src/app/views/router.py
Normal file
10
src/app/views/router.py
Normal file
@ -0,0 +1,10 @@
|
||||
from fastapi import APIRouter
|
||||
from app.views.routers import (
|
||||
main,
|
||||
user
|
||||
)
|
||||
|
||||
|
||||
main_router = APIRouter()
|
||||
main_router.include_router(main.router, tags=['main'])
|
||||
main_router.include_router(user.router, tags=['user'])
|
0
src/app/views/routers/__init__.py
Normal file
0
src/app/views/routers/__init__.py
Normal file
60
src/app/views/routers/main.py
Normal file
60
src/app/views/routers/main.py
Normal file
@ -0,0 +1,60 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Query,
|
||||
Request,
|
||||
Depends
|
||||
)
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app import crud
|
||||
from app.core.config import settings
|
||||
from app.schemas import ListPostInTemplate
|
||||
from app.views.depends import get_async_db
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
templates = Jinja2Templates(directory=settings.APP_PATH / 'templates')
|
||||
|
||||
|
||||
@router.get('/', response_class=HTMLResponse)
|
||||
async def home(request: Request):
|
||||
return templates.TemplateResponse(
|
||||
'index.html',
|
||||
{"request": request}
|
||||
)
|
||||
|
||||
|
||||
@router.get('/blog', response_class=HTMLResponse)
|
||||
async def blog(
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
skip: Annotated[int, Query(gt=-1)] = 0,
|
||||
new: bool = False
|
||||
):
|
||||
|
||||
if new:
|
||||
skip -= 10
|
||||
if skip < 0:
|
||||
skip = 0
|
||||
|
||||
posts = await crud.post.get_multi(db, skip=skip, limit=6)
|
||||
posts = ListPostInTemplate.validate_python(posts)
|
||||
|
||||
if len(posts) == 6:
|
||||
skip += 5
|
||||
posts = posts[:-1]
|
||||
|
||||
return templates.TemplateResponse(
|
||||
'blog.html',
|
||||
{
|
||||
'request': request,
|
||||
'posts': posts,
|
||||
'skip': skip
|
||||
}
|
||||
)
|
77
src/app/views/routers/user.py
Normal file
77
src/app/views/routers/user.py
Normal file
@ -0,0 +1,77 @@
|
||||
from typing import Any
|
||||
from datetime import timedelta
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
HTTPException,
|
||||
Request,
|
||||
Depends
|
||||
)
|
||||
|
||||
from app import crud
|
||||
from app.core import security
|
||||
from app.models.user import User as UserModel
|
||||
from app.schemas import JWTToken, LoginForm
|
||||
from app.core.config import settings
|
||||
from app.views.depends import (
|
||||
get_async_db,
|
||||
get_current_active_superuser
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
templates = Jinja2Templates(directory=settings.APP_PATH / 'templates')
|
||||
|
||||
|
||||
@router.get('/login', response_class=HTMLResponse)
|
||||
async def login(
|
||||
request: Request
|
||||
):
|
||||
|
||||
return templates.TemplateResponse(
|
||||
'admin/login.html',
|
||||
{
|
||||
'request': request
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=JWTToken)
|
||||
async def login(
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
form_data: LoginForm = Depends()
|
||||
) -> Any:
|
||||
|
||||
user = await crud.user.authenticate(
|
||||
db, email=form_data.email, password=form_data.password
|
||||
)
|
||||
|
||||
if user is None:
|
||||
raise HTTPException(status_code=400, detail="Incorrect email or password")
|
||||
|
||||
elif user.is_active is False:
|
||||
raise HTTPException(status_code=400, detail="Inactive user")
|
||||
|
||||
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
|
||||
return {
|
||||
"access_token": security.create_access_token(
|
||||
user.email,
|
||||
expires_delta=access_token_expires
|
||||
),
|
||||
"token_type": "bearer",
|
||||
}
|
||||
|
||||
|
||||
# @router.get("/test")
|
||||
# def test(
|
||||
# request: Request,
|
||||
# user: UserModel = Depends(get_current_active_superuser),
|
||||
# ) -> Any:
|
||||
|
||||
# return user.email
|
1
src/migrations/README
Normal file
1
src/migrations/README
Normal file
@ -0,0 +1 @@
|
||||
Generic single-database configuration with an async dbapi.
|
117
src/migrations/env.py
Normal file
117
src/migrations/env.py
Normal file
@ -0,0 +1,117 @@
|
||||
import asyncio
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
from alembic import context
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
# from myapp import mymodel
|
||||
# target_metadata = mymodel.Base.metadata
|
||||
|
||||
########### Custom Config ###########
|
||||
from os import getenv
|
||||
print('-'*100)
|
||||
print(getenv('POSTGRES_USER'))
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.base import Base
|
||||
from app.models import (
|
||||
Post,
|
||||
User
|
||||
)
|
||||
|
||||
config.set_main_option(
|
||||
'sqlalchemy.url',
|
||||
str(settings.get_postgres_dsn(_async=True))
|
||||
)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
########### Custom Config End ###########
|
||||
|
||||
# other values from the config, defined by the n=eeds of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
compare_type=True,
|
||||
compare_server_default=True,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection: Connection) -> None:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
compare_type=True,
|
||||
compare_server_default=True
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
"""In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
|
||||
connectable = async_engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode."""
|
||||
|
||||
asyncio.run(run_async_migrations())
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
26
src/migrations/script.py.mako
Normal file
26
src/migrations/script.py.mako
Normal file
@ -0,0 +1,26 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
69
src/migrations/versions/ac935b3059fa_init.py
Normal file
69
src/migrations/versions/ac935b3059fa_init.py
Normal file
@ -0,0 +1,69 @@
|
||||
"""init
|
||||
|
||||
Revision ID: ac935b3059fa
|
||||
Revises:
|
||||
Create Date: 2023-09-10 22:30:37.675996
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'ac935b3059fa'
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('user',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('username', sa.String(), nullable=True),
|
||||
sa.Column('email', sa.String(), nullable=False),
|
||||
sa.Column('hashed_password', sa.String(), nullable=False),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=True),
|
||||
sa.Column('is_superuser', sa.Boolean(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_user_email'), 'user', ['email'], unique=True)
|
||||
op.create_index(op.f('ix_user_id'), 'user', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_user_username'), 'user', ['username'], unique=False)
|
||||
op.create_table('post',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('title', sa.String(length=100), nullable=False),
|
||||
sa.Column('slug', sa.String(), nullable=False),
|
||||
sa.Column('text', sa.String(), nullable=False),
|
||||
sa.Column('image_path', sa.String(length=100), nullable=True),
|
||||
sa.Column('owner_id', sa.Integer(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(['owner_id'], ['user.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_post_id'), 'post', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_post_image_path'), 'post', ['image_path'], unique=True)
|
||||
op.create_index(op.f('ix_post_slug'), 'post', ['slug'], unique=True)
|
||||
op.create_index(op.f('ix_post_text'), 'post', ['text'], unique=False)
|
||||
op.create_index(op.f('ix_post_title'), 'post', ['title'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_post_title'), table_name='post')
|
||||
op.drop_index(op.f('ix_post_text'), table_name='post')
|
||||
op.drop_index(op.f('ix_post_slug'), table_name='post')
|
||||
op.drop_index(op.f('ix_post_image_path'), table_name='post')
|
||||
op.drop_index(op.f('ix_post_id'), table_name='post')
|
||||
op.drop_table('post')
|
||||
op.drop_index(op.f('ix_user_username'), table_name='user')
|
||||
op.drop_index(op.f('ix_user_id'), table_name='user')
|
||||
op.drop_index(op.f('ix_user_email'), table_name='user')
|
||||
op.drop_table('user')
|
||||
# ### end Alembic commands ###
|
1067
src/poetry.lock
generated
Normal file
1067
src/poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
src/pyproject.toml
Normal file
29
src/pyproject.toml
Normal file
@ -0,0 +1,29 @@
|
||||
[tool.poetry]
|
||||
name = "portfolio-blog"
|
||||
version = "0.1.0"
|
||||
description = ""
|
||||
authors = ["Aykhan <aykhan.shahs0@gmail.com>"]
|
||||
readme = "README.md"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.10"
|
||||
fastapi = "^0.103.1"
|
||||
uvicorn = "^0.23.2"
|
||||
jinja2 = "^3.1.2"
|
||||
sqlalchemy = "^2.0.20"
|
||||
pydantic-settings = "^2.0.3"
|
||||
alembic = "^1.12.0"
|
||||
asyncpg = "^0.28.0"
|
||||
psycopg2 = "^2.9.7"
|
||||
passlib = "^1.7.4"
|
||||
pydantic = {extras = ["email"], version = "^2.3.0"}
|
||||
python-multipart = "^0.0.6"
|
||||
python-slugify = "^8.0.1"
|
||||
pillow = "^10.0.0"
|
||||
aiofiles = "^23.2.1"
|
||||
python-jose = {extras = ["cryptography"], version = "^3.3.0"}
|
||||
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core"]
|
||||
build-backend = "poetry.core.masonry.api"
|
Loading…
x
Reference in New Issue
Block a user