├── utils ├── __init__.py └── helpers.py ├── commands ├── __init__.py └── create_super_user.py ├── managers ├── __init__.py ├── user.py ├── auth.py └── complaint.py ├── resources ├── __init__.py ├── routes.py ├── auth.py ├── user.py └── complaint.py ├── schemas ├── __init__.py ├── request │ ├── __init__.py │ ├── complaint.py │ └── user.py ├── response │ ├── __init__.py │ ├── user.py │ └── complaint.py └── base.py ├── services ├── __init__.py ├── ses.py ├── s3.py └── wise.py ├── migrations ├── README ├── script.py.mako ├── versions │ ├── f62998dbc8f0_add_phone.py │ ├── 72721fc57788_add_transaction.py │ └── 692aa87af979_initial.py └── env.py ├── models ├── __init__.py ├── enums.py ├── transaction.py ├── user.py └── complaint.py ├── constants.py ├── test_main.http ├── db.py ├── main.py ├── requirements.txt ├── alembic.ini └── .gitignore /utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /managers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /schemas/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /services/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /schemas/request/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /schemas/response/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /models/__init__.py: -------------------------------------------------------------------------------- 1 | from models.complaint import * 2 | from models.user import * 3 | from models.transaction import * 4 | -------------------------------------------------------------------------------- /constants.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) 4 | TEMP_FILE_FOLDER = os.path.join(ROOT_DIR, "temp_files") 5 | -------------------------------------------------------------------------------- /schemas/request/complaint.py: -------------------------------------------------------------------------------- 1 | from schemas.base import BaseComplaint 2 | 3 | 4 | class ComplaintIn(BaseComplaint): 5 | encoded_photo: str 6 | extension: str 7 | 8 | -------------------------------------------------------------------------------- /schemas/base.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class UserBase(BaseModel): 5 | email: str 6 | 7 | 8 | class BaseComplaint(BaseModel): 9 | title: str 10 | description: str 11 | amount: float -------------------------------------------------------------------------------- /test_main.http: -------------------------------------------------------------------------------- 1 | # Test your FastAPI endpoints 2 | 3 | GET http://127.0.0.1:8000/ 4 | Accept: application/json 5 | 6 | ### 7 | 8 | GET http://127.0.0.1:8000/hello/User 9 | Accept: application/json 10 | 11 | ### 12 | -------------------------------------------------------------------------------- /schemas/response/user.py: -------------------------------------------------------------------------------- 1 | from models import RoleType 2 | from schemas.base import UserBase 3 | 4 | 5 | class UserOut(UserBase): 6 | id: int 7 | first_name: str 8 | last_name: str 9 | phone: str 10 | role: RoleType 11 | iban: str -------------------------------------------------------------------------------- /resources/routes.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | from resources import auth, complaint, user 3 | 4 | api_router = APIRouter() 5 | api_router.include_router(auth.router) 6 | api_router.include_router(complaint.router) 7 | api_router.include_router(user.router) 8 | -------------------------------------------------------------------------------- /db.py: -------------------------------------------------------------------------------- 1 | import databases 2 | import sqlalchemy 3 | from decouple import config 4 | 5 | DATABASE_URL = f"postgresql://{config('DB_USER')}:{config('DB_PASSWORD')}@localhost:5433/complaints" 6 | database = databases.Database(DATABASE_URL) 7 | metadata = sqlalchemy.MetaData() 8 | -------------------------------------------------------------------------------- /schemas/request/user.py: -------------------------------------------------------------------------------- 1 | from schemas.base import UserBase 2 | 3 | 4 | class UserRegisterIn(UserBase): 5 | password: str 6 | phone: str 7 | first_name: str 8 | last_name: str 9 | iban: str 10 | 11 | 12 | class UserLoginIn(UserBase): 13 | password: str 14 | -------------------------------------------------------------------------------- /schemas/response/complaint.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from schemas.base import BaseComplaint 4 | 5 | from models import State 6 | 7 | 8 | class ComplaintOut(BaseComplaint): 9 | id: int 10 | photo_url: str 11 | created_at: datetime 12 | status: State -------------------------------------------------------------------------------- /models/enums.py: -------------------------------------------------------------------------------- 1 | import enum 2 | 3 | 4 | class RoleType(enum.Enum): 5 | approver = "approver" 6 | complainer = "complainer" 7 | admin = "admin" 8 | 9 | 10 | class State(enum.Enum): 11 | pending = "Pending" 12 | approved = "Approved" 13 | rejected = "Rejected" 14 | -------------------------------------------------------------------------------- /utils/helpers.py: -------------------------------------------------------------------------------- 1 | import base64 2 | 3 | from fastapi import HTTPException 4 | 5 | 6 | def decode_photo(path, encoded_string): 7 | with open(path, "wb") as f: 8 | try: 9 | f.write(base64.b64decode(encoded_string.encode("utf-8"))) 10 | except Exception as ex: 11 | raise HTTPException(400, "Invalid photo encoding") -------------------------------------------------------------------------------- /resources/auth.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from managers.user import UserManager 4 | from schemas.request.user import UserRegisterIn, UserLoginIn 5 | 6 | router = APIRouter(tags=["Auth"]) 7 | 8 | 9 | @router.post("/register/", status_code=201) 10 | async def register(user_data: UserRegisterIn): 11 | token = await UserManager.register(user_data.dict()) 12 | return {"token": token} 13 | 14 | 15 | @router.post("/login/") 16 | async def login(user_data: UserLoginIn): 17 | token, role = await UserManager.login(user_data.dict()) 18 | return {"token": token, "role": role} 19 | -------------------------------------------------------------------------------- /migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | from fastapi.middleware.cors import CORSMiddleware 3 | 4 | from db import database 5 | from resources.routes import api_router 6 | 7 | origins = [ 8 | "http://localhost", 9 | "http://localhost:4200" 10 | ] 11 | 12 | app = FastAPI() 13 | app.include_router(api_router) 14 | app.add_middleware( 15 | CORSMiddleware, 16 | allow_origins=origins, 17 | allow_credentials=True, 18 | allow_methods=["*"], 19 | allow_headers=["*"], 20 | ) 21 | 22 | 23 | @app.on_event("startup") 24 | async def startup(): 25 | await database.connect() 26 | 27 | 28 | @app.on_event("shutdown") 29 | async def shutdown(): 30 | await database.disconnect() -------------------------------------------------------------------------------- /models/transaction.py: -------------------------------------------------------------------------------- 1 | import sqlalchemy 2 | from db import metadata 3 | 4 | 5 | transaction = sqlalchemy.Table( 6 | "transactions", 7 | metadata, 8 | sqlalchemy.Column("id", sqlalchemy.Integer, primary_key=True), 9 | sqlalchemy.Column("quote_id", sqlalchemy.String(120), nullable=False), 10 | sqlalchemy.Column("transfer_id", sqlalchemy.Integer, nullable=False), 11 | sqlalchemy.Column("target_account_id", sqlalchemy.String(100), nullable=False), 12 | sqlalchemy.Column("amount", sqlalchemy.Float, nullable=False), 13 | sqlalchemy.Column("amount", sqlalchemy.Float, nullable=False), 14 | sqlalchemy.Column("complaint_id", sqlalchemy.ForeignKey("complaints.id")) 15 | ) -------------------------------------------------------------------------------- /models/user.py: -------------------------------------------------------------------------------- 1 | import sqlalchemy 2 | 3 | from db import metadata 4 | from models.enums import RoleType 5 | 6 | user = sqlalchemy.Table( 7 | "users", 8 | metadata, 9 | sqlalchemy.Column("id", sqlalchemy.Integer, primary_key=True), 10 | sqlalchemy.Column("email", sqlalchemy.String(120), unique=True), 11 | sqlalchemy.Column("password", sqlalchemy.String(255)), 12 | sqlalchemy.Column("first_name", sqlalchemy.String(200)), 13 | sqlalchemy.Column("last_name", sqlalchemy.String(200)), 14 | sqlalchemy.Column("phone", sqlalchemy.String(20)), 15 | sqlalchemy.Column("role", sqlalchemy.Enum(RoleType), nullable=False, 16 | server_default=RoleType.complainer.name), 17 | sqlalchemy.Column("iban", sqlalchemy.String(200)) 18 | ) -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | alembic==1.7.5 2 | anyio==3.4.0 3 | asgiref==3.4.1 4 | asyncclick==8.0.1.3 5 | asyncpg==0.25.0 6 | bcrypt==3.2.0 7 | cffi==1.15.0 8 | click==8.0.3 9 | colorama==0.4.4 10 | databases==0.5.3 11 | env-config==1.10.0 12 | fastapi==0.70.1 13 | greenlet==1.1.2 14 | h11==0.12.0 15 | idna==3.3 16 | importlib-metadata==4.10.0 17 | importlib-resources==5.4.0 18 | Mako==1.1.6 19 | MarkupSafe==2.0.1 20 | passlib==1.7.4 21 | password==0.2 22 | psycopg2==2.9.3 23 | psycopg2-binary==2.9.3 24 | pycparser==2.21 25 | pydantic==1.9.0 26 | PyJWT==2.3.0 27 | python-decouple==3.5 28 | python-dotenv==0.19.2 29 | PyYAML==6.0 30 | six==1.16.0 31 | sniffio==1.2.0 32 | SQLAlchemy==1.4.29 33 | starlette==0.16.0 34 | typing_extensions==4.0.1 35 | uvicorn==0.16.0 36 | watchgod==0.7 37 | websockets==10.1 38 | zipp==3.7.0 39 | -------------------------------------------------------------------------------- /migrations/versions/f62998dbc8f0_add_phone.py: -------------------------------------------------------------------------------- 1 | """Add phone 2 | 3 | Revision ID: f62998dbc8f0 4 | Revises: 692aa87af979 5 | Create Date: 2022-01-02 13:34:57.677831 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'f62998dbc8f0' 14 | down_revision = '692aa87af979' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.add_column('users', sa.Column('phone', sa.String(length=20), nullable=True)) 22 | # ### end Alembic commands ### 23 | 24 | 25 | def downgrade(): 26 | # ### commands auto generated by Alembic - please adjust! ### 27 | op.drop_column('users', 'phone') 28 | # ### end Alembic commands ### 29 | -------------------------------------------------------------------------------- /models/complaint.py: -------------------------------------------------------------------------------- 1 | import sqlalchemy 2 | 3 | from db import metadata 4 | from models.enums import State 5 | 6 | complaint = sqlalchemy.Table( 7 | "complaints", 8 | metadata, 9 | sqlalchemy.Column("id", sqlalchemy.Integer, primary_key=True), 10 | sqlalchemy.Column("title", sqlalchemy.String(120), nullable=False), 11 | sqlalchemy.Column("description", sqlalchemy.Text, nullable=False), 12 | sqlalchemy.Column("photo_url", sqlalchemy.String(200), nullable=False), 13 | sqlalchemy.Column("amount", sqlalchemy.Float, nullable=False), 14 | sqlalchemy.Column("created_at", sqlalchemy.DateTime, server_default=sqlalchemy.func.now()), 15 | sqlalchemy.Column("status", sqlalchemy.Enum(State), nullable=False, server_default=State.pending.name), 16 | sqlalchemy.Column("complainer_id", sqlalchemy.ForeignKey("users.id"), nullable=False) 17 | ) -------------------------------------------------------------------------------- /services/ses.py: -------------------------------------------------------------------------------- 1 | import boto3 2 | from decouple import config 3 | 4 | 5 | class SESService: 6 | def __init__(self): 7 | self.key = config("AWS_ACCESS_KEY") 8 | self.secret = config("AWS_SECRET") 9 | self.ses = boto3.client( 10 | "ses", 11 | region_name=config("SES_REGION"), 12 | aws_access_key_id=self.key, 13 | aws_secret_access_key=self.secret, 14 | ) 15 | 16 | def send_mail(self, subject, to_addresses, text_data): 17 | body = {"Text": {"Data": text_data, "Charset": "UTF-8"}} 18 | 19 | self.ses.send_email( 20 | Source="codewithfinesse@gmail.com", 21 | Destination={ 22 | "ToAddresses": to_addresses, 23 | "CcAddresses": [], 24 | "BccAddresses": [], 25 | }, 26 | Message={"Subject": {"Data": subject, "Charset": "UTF-8"}, "Body": body}, 27 | ), 28 | -------------------------------------------------------------------------------- /services/s3.py: -------------------------------------------------------------------------------- 1 | import boto3 2 | from decouple import config 3 | from fastapi import HTTPException 4 | 5 | 6 | class S3Service: 7 | def __init__(self): 8 | self.key = config("AWS_ACCESS_KEY") 9 | self.secret = config("AWS_SECRET") 10 | self.s3 = boto3.client( 11 | "s3", aws_access_key_id=self.key, aws_secret_access_key=self.secret 12 | ) 13 | self.bucket = config("AWS_BUCKET_NAME") 14 | self.region = config("AWS_REGION") 15 | 16 | def upload(self, path, key, ext): 17 | try: 18 | self.s3.upload_file( 19 | path, 20 | self.bucket, 21 | key, 22 | ExtraArgs={"ACL": "public-read", "ContentType": f"image/{ext}"}, 23 | ) 24 | return f"https://{self.bucket}.s3.{self.region}.amazonaws.com/{key}" 25 | except Exception as ex: 26 | raise HTTPException(500, "S3 is not available") 27 | -------------------------------------------------------------------------------- /commands/create_super_user.py: -------------------------------------------------------------------------------- 1 | import asyncclick as click 2 | 3 | from db import database 4 | from managers.user import UserManager 5 | from models import RoleType 6 | 7 | 8 | @click.command() 9 | @click.option("-f", "--first_name", type=str, required=True) 10 | @click.option("-l", "--last_name", type=str, required=True) 11 | @click.option("-e", "--email", type=str, required=True) 12 | @click.option("-p", "--phone", type=str, required=True) 13 | @click.option("-i", "--iban", type=str, required=True) 14 | @click.option("-pa", "--password", type=str, required=True) 15 | async def create_user(first_name, last_name, email, phone, iban, password): 16 | user_data = { 17 | "first_name": first_name, 18 | "last_name": last_name, 19 | "email": email, 20 | "phone": phone, 21 | "iban": iban, 22 | "password": password, 23 | "role": RoleType.admin, 24 | } 25 | await database.connect() 26 | await UserManager.register(user_data) 27 | await database.disconnect() 28 | 29 | 30 | if __name__ == "__main__": 31 | create_user(_anyio_backend="asyncio") 32 | -------------------------------------------------------------------------------- /resources/user.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, List 2 | 3 | from fastapi import APIRouter, Depends 4 | 5 | from managers.auth import oauth2_scheme, is_admin 6 | from managers.user import UserManager 7 | from models import RoleType 8 | from schemas.response.user import UserOut 9 | 10 | router = APIRouter(tags=["Users"]) 11 | 12 | 13 | @router.get( 14 | "/users/", 15 | dependencies=[Depends(oauth2_scheme), Depends(is_admin)], 16 | response_model=List[UserOut], 17 | ) 18 | async def get_users(email: Optional[str] = None): 19 | if email: 20 | return await UserManager.get_user_by_email(email) 21 | return await UserManager.get_all_users() 22 | 23 | 24 | @router.put( 25 | "/users/{user_id}/make-admin", 26 | dependencies=[Depends(oauth2_scheme), Depends(is_admin)], 27 | status_code=204, 28 | ) 29 | async def make_admin(user_id: int): 30 | await UserManager.change_role(RoleType.admin, user_id) 31 | 32 | 33 | @router.put( 34 | "/users/{user_id}/make-approver", 35 | dependencies=[Depends(oauth2_scheme), Depends(is_admin)], 36 | status_code=204, 37 | ) 38 | async def make_approver(user_id: int): 39 | await UserManager.change_role(RoleType.approver, user_id) 40 | -------------------------------------------------------------------------------- /migrations/versions/72721fc57788_add_transaction.py: -------------------------------------------------------------------------------- 1 | """add transaction 2 | 3 | Revision ID: 72721fc57788 4 | Revises: f62998dbc8f0 5 | Create Date: 2022-01-08 14:55:29.750587 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '72721fc57788' 14 | down_revision = 'f62998dbc8f0' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('transactions', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('quote_id', sa.String(length=120), nullable=False), 24 | sa.Column('transfer_id', sa.Integer(), nullable=False), 25 | sa.Column('target_account_id', sa.String(length=100), nullable=False), 26 | sa.Column('amount', sa.Float(), nullable=False), 27 | sa.Column('complaint_id', sa.Integer(), nullable=True), 28 | sa.ForeignKeyConstraint(['complaint_id'], ['complaints.id'], ), 29 | sa.PrimaryKeyConstraint('id') 30 | ) 31 | # ### end Alembic commands ### 32 | 33 | 34 | def downgrade(): 35 | # ### commands auto generated by Alembic - please adjust! ### 36 | op.drop_table('transactions') 37 | # ### end Alembic commands ### 38 | -------------------------------------------------------------------------------- /managers/user.py: -------------------------------------------------------------------------------- 1 | from fastapi import HTTPException 2 | from passlib.context import CryptContext 3 | from asyncpg import UniqueViolationError 4 | from db import database 5 | from managers.auth import AuthManager 6 | from models import user, RoleType 7 | 8 | pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") 9 | 10 | 11 | class UserManager: 12 | @staticmethod 13 | async def register(user_data): 14 | user_data["password"] = pwd_context.hash(user_data["password"]) 15 | try: 16 | id_ = await database.execute(user.insert().values(**user_data)) 17 | except UniqueViolationError: 18 | raise HTTPException(400, "User with this email already exists") 19 | user_do = await database.fetch_one(user.select().where(user.c.id == id_)) 20 | return AuthManager.encode_token(user_do) 21 | 22 | @staticmethod 23 | async def login(user_data): 24 | user_do = await database.fetch_one( 25 | user.select().where(user.c.email == user_data["email"]) 26 | ) 27 | if not user_do: 28 | raise HTTPException(400, "Wrong email or password") 29 | elif not pwd_context.verify(user_data["password"], user_do["password"]): 30 | raise HTTPException(400, "Wrong email or password") 31 | return AuthManager.encode_token(user_do), user_do["role"] 32 | 33 | @staticmethod 34 | async def get_all_users(): 35 | return await database.fetch_all(user.select()) 36 | 37 | @staticmethod 38 | async def get_user_by_email(email): 39 | return await database.fetch_all(user.select().where(user.c.email == email)) 40 | 41 | @staticmethod 42 | async def change_role(role: RoleType, user_id): 43 | await database.execute( 44 | user.update().where(user.c.id == user_id).values(role=role) 45 | ) 46 | -------------------------------------------------------------------------------- /resources/complaint.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from fastapi import APIRouter, Depends 4 | from starlette.requests import Request 5 | 6 | from managers.auth import oauth2_scheme, is_complainer, is_admin, is_approver 7 | from managers.complaint import ComplaintManager 8 | from schemas.request.complaint import ComplaintIn 9 | from schemas.response.complaint import ComplaintOut 10 | 11 | router = APIRouter(tags=["Complaints"]) 12 | 13 | 14 | @router.get( 15 | "/complaints/", 16 | dependencies=[Depends(oauth2_scheme)], 17 | response_model=List[ComplaintOut], 18 | ) 19 | async def get_complaints(request: Request): 20 | user = request.state.user 21 | return await ComplaintManager.get_complaints(user) 22 | 23 | 24 | @router.post( 25 | "/complaints/", 26 | dependencies=[Depends(oauth2_scheme), Depends(is_complainer)], 27 | response_model=ComplaintOut, 28 | ) 29 | async def create_complaint(request: Request, complaint: ComplaintIn): 30 | user = request.state.user 31 | return await ComplaintManager.create_complaint(complaint.dict(), user) 32 | 33 | 34 | @router.delete( 35 | "/complaints/{complaint_id}/", 36 | dependencies=[Depends(oauth2_scheme), Depends(is_admin)], 37 | status_code=204, 38 | ) 39 | async def delete_complaint(complaint_id: int): 40 | await ComplaintManager.delete(complaint_id) 41 | 42 | 43 | @router.put( 44 | "/complaints/{complaint_id}/approve", 45 | dependencies=[Depends(oauth2_scheme), Depends(is_approver)], 46 | status_code=204, 47 | ) 48 | async def approve_complaint(complaint_id: int): 49 | await ComplaintManager.approve(complaint_id) 50 | 51 | 52 | @router.put( 53 | "/complaints/{complaint_id}/reject", 54 | dependencies=[Depends(oauth2_scheme), Depends(is_approver)], 55 | status_code=204, 56 | ) 57 | async def reject_complaint(complaint_id: int): 58 | await ComplaintManager.reject(complaint_id) 59 | -------------------------------------------------------------------------------- /migrations/versions/692aa87af979_initial.py: -------------------------------------------------------------------------------- 1 | """Initial 2 | 3 | Revision ID: 692aa87af979 4 | Revises: 5 | Create Date: 2022-01-02 12:41:04.987284 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '692aa87af979' 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('users', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('email', sa.String(length=120), nullable=True), 24 | sa.Column('password', sa.String(length=255), nullable=True), 25 | sa.Column('first_name', sa.String(length=200), nullable=True), 26 | sa.Column('last_name', sa.String(length=20), nullable=True), 27 | sa.Column('role', sa.Enum('approver', 'complainer', 'admin', name='roletype'), server_default='complainer', nullable=False), 28 | sa.Column('iban', sa.String(length=200), nullable=True), 29 | sa.PrimaryKeyConstraint('id'), 30 | sa.UniqueConstraint('email') 31 | ) 32 | op.create_table('complaints', 33 | sa.Column('id', sa.Integer(), nullable=False), 34 | sa.Column('title', sa.String(length=120), nullable=False), 35 | sa.Column('description', sa.Text(), nullable=False), 36 | sa.Column('photo_url', sa.String(length=200), nullable=False), 37 | sa.Column('amount', sa.Float(), nullable=False), 38 | sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True), 39 | sa.Column('status', sa.Enum('pending', 'approved', 'rejected', name='state'), server_default='pending', nullable=False), 40 | sa.Column('complainer_id', sa.Integer(), nullable=False), 41 | sa.ForeignKeyConstraint(['complainer_id'], ['users.id'], ), 42 | sa.PrimaryKeyConstraint('id') 43 | ) 44 | # ### end Alembic commands ### 45 | 46 | 47 | def downgrade(): 48 | # ### commands auto generated by Alembic - please adjust! ### 49 | op.drop_table('complaints') 50 | op.drop_table('users') 51 | # ### end Alembic commands ### 52 | -------------------------------------------------------------------------------- /managers/auth.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from typing import Optional 3 | 4 | import jwt 5 | from decouple import config 6 | from fastapi import HTTPException 7 | from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials 8 | from starlette.requests import Request 9 | 10 | from db import database 11 | from models import user, RoleType 12 | 13 | 14 | class AuthManager: 15 | @staticmethod 16 | def encode_token(user): 17 | try: 18 | payload = { 19 | "sub": user["id"], 20 | "exp": datetime.utcnow() + timedelta(minutes=120), 21 | } 22 | return jwt.encode(payload, config("SECRET_KEY"), algorithm="HS256") 23 | except Exception as ex: 24 | # Log the exception 25 | raise ex 26 | 27 | 28 | class CustomHHTPBearer(HTTPBearer): 29 | async def __call__( 30 | self, request: Request 31 | ) -> Optional[HTTPAuthorizationCredentials]: 32 | res = await super().__call__(request) 33 | 34 | try: 35 | payload = jwt.decode( 36 | res.credentials, config("SECRET_KEY"), algorithms=["HS256"] 37 | ) 38 | user_data = await database.fetch_one( 39 | user.select().where(user.c.id == payload["sub"]) 40 | ) 41 | request.state.user = user_data 42 | return user_data 43 | except jwt.ExpiredSignatureError: 44 | raise HTTPException(401, "Token is expired") 45 | except jwt.InvalidTokenError: 46 | raise HTTPException(401, "Invalid token") 47 | 48 | 49 | oauth2_scheme = CustomHHTPBearer() 50 | 51 | 52 | def is_complainer(request: Request): 53 | if not request.state.user["role"] == RoleType.complainer: 54 | raise HTTPException(403, "Forbidden") 55 | 56 | 57 | def is_approver(request: Request): 58 | if not request.state.user["role"] == RoleType.approver: 59 | raise HTTPException(403, "Forbidden") 60 | 61 | 62 | def is_admin(request: Request): 63 | if not request.state.user["role"] == RoleType.admin: 64 | raise HTTPException(403, "Forbidden") 65 | -------------------------------------------------------------------------------- /migrations/env.py: -------------------------------------------------------------------------------- 1 | from logging.config import fileConfig 2 | 3 | import env_config as env_config 4 | from sqlalchemy import engine_from_config 5 | from sqlalchemy import pool 6 | 7 | from alembic import context 8 | from decouple import config as env_config 9 | from db import metadata 10 | import models 11 | 12 | # this is the Alembic Config object, which provides 13 | # access to the values within the .ini file in use. 14 | config = context.config 15 | 16 | 17 | section = config.config_ini_section 18 | config.set_section_option(section, "DB_USER", env_config("DB_USER")) 19 | config.set_section_option(section, "DB_PASS", env_config("DB_PASSWORD")) 20 | 21 | # Interpret the config file for Python logging. 22 | # This line sets up loggers basically. 23 | fileConfig(config.config_file_name) 24 | 25 | # add your model's MetaData object here 26 | # for 'autogenerate' support 27 | # from myapp import mymodel 28 | # target_metadata = mymodel.Base.metadata 29 | target_metadata = metadata 30 | 31 | # other values from the config, defined by the needs of env.py, 32 | # can be acquired: 33 | # my_important_option = config.get_main_option("my_important_option") 34 | # ... etc. 35 | 36 | 37 | def run_migrations_offline(): 38 | """Run migrations in 'offline' mode. 39 | 40 | This configures the context with just a URL 41 | and not an Engine, though an Engine is acceptable 42 | here as well. By skipping the Engine creation 43 | we don't even need a DBAPI to be available. 44 | 45 | Calls to context.execute() here emit the given string to the 46 | script output. 47 | 48 | """ 49 | url = config.get_main_option("sqlalchemy.url") 50 | context.configure( 51 | url=url, 52 | target_metadata=target_metadata, 53 | literal_binds=True, 54 | dialect_opts={"paramstyle": "named"}, 55 | ) 56 | 57 | with context.begin_transaction(): 58 | context.run_migrations() 59 | 60 | 61 | def run_migrations_online(): 62 | """Run migrations in 'online' mode. 63 | 64 | In this scenario we need to create an Engine 65 | and associate a connection with the context. 66 | 67 | """ 68 | connectable = engine_from_config( 69 | config.get_section(config.config_ini_section), 70 | prefix="sqlalchemy.", 71 | poolclass=pool.NullPool, 72 | ) 73 | 74 | with connectable.connect() as connection: 75 | context.configure( 76 | connection=connection, target_metadata=target_metadata 77 | ) 78 | 79 | with context.begin_transaction(): 80 | context.run_migrations() 81 | 82 | 83 | if context.is_offline_mode(): 84 | run_migrations_offline() 85 | else: 86 | run_migrations_online() 87 | -------------------------------------------------------------------------------- /alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = migrations 6 | 7 | # template used to generate migration files 8 | # file_template = %%(rev)s_%%(slug)s 9 | 10 | # sys.path path, will be prepended to sys.path if present. 11 | # defaults to the current working directory. 12 | prepend_sys_path = . 13 | 14 | # timezone to use when rendering the date within the migration file 15 | # as well as the filename. 16 | # If specified, requires the python-dateutil library that can be 17 | # installed by adding `alembic[tz]` to the pip requirements 18 | # string value is passed to dateutil.tz.gettz() 19 | # leave blank for localtime 20 | # timezone = 21 | 22 | # max length of characters to apply to the 23 | # "slug" field 24 | # truncate_slug_length = 40 25 | 26 | # set to 'true' to run the environment during 27 | # the 'revision' command, regardless of autogenerate 28 | # revision_environment = false 29 | 30 | # set to 'true' to allow .pyc and .pyo files without 31 | # a source .py file to be detected as revisions in the 32 | # versions/ directory 33 | # sourceless = false 34 | 35 | # version location specification; This defaults 36 | # to migrations/versions. When using multiple version 37 | # directories, initial revisions must be specified with --version-path. 38 | # The path separator used here should be the separator specified by "version_path_separator" 39 | # version_locations = %(here)s/bar:%(here)s/bat:migrations/versions 40 | 41 | # version path separator; As mentioned above, this is the character used to split 42 | # version_locations. Valid values are: 43 | # 44 | # version_path_separator = : 45 | # version_path_separator = ; 46 | # version_path_separator = space 47 | version_path_separator = os # default: use os.pathsep 48 | 49 | # the output encoding used when revision files 50 | # are written from script.py.mako 51 | # output_encoding = utf-8 52 | 53 | sqlalchemy.url = postgresql://%(DB_USER)s:%(DB_PASS)s@localhost:5433/complaints 54 | 55 | 56 | [post_write_hooks] 57 | # post_write_hooks defines scripts or Python functions that are run 58 | # on newly generated revision scripts. See the documentation for further 59 | # detail and examples 60 | 61 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 62 | # hooks = black 63 | # black.type = console_scripts 64 | # black.entrypoint = black 65 | # black.options = -l 79 REVISION_SCRIPT_FILENAME 66 | 67 | # Logging configuration 68 | [loggers] 69 | keys = root,sqlalchemy,alembic 70 | 71 | [handlers] 72 | keys = console 73 | 74 | [formatters] 75 | keys = generic 76 | 77 | [logger_root] 78 | level = WARN 79 | handlers = console 80 | qualname = 81 | 82 | [logger_sqlalchemy] 83 | level = WARN 84 | handlers = 85 | qualname = sqlalchemy.engine 86 | 87 | [logger_alembic] 88 | level = INFO 89 | handlers = 90 | qualname = alembic 91 | 92 | [handler_console] 93 | class = StreamHandler 94 | args = (sys.stderr,) 95 | level = NOTSET 96 | formatter = generic 97 | 98 | [formatter_generic] 99 | format = %(levelname)-5.5s [%(name)s] %(message)s 100 | datefmt = %H:%M:%S 101 | -------------------------------------------------------------------------------- /.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 dependencies that don't work, or not 94 | # install 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 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 105 | __pypackages__/ 106 | 107 | # Celery stuff 108 | celerybeat-schedule 109 | celerybeat.pid 110 | 111 | # SageMath parsed files 112 | *.sage.py 113 | 114 | # Environments 115 | .env 116 | .venv 117 | env/ 118 | venv/ 119 | ENV/ 120 | env.bak/ 121 | venv.bak/ 122 | 123 | # Spyder project settings 124 | .spyderproject 125 | .spyproject 126 | 127 | # Rope project settings 128 | .ropeproject 129 | 130 | # mkdocs documentation 131 | /site 132 | 133 | # mypy 134 | .mypy_cache/ 135 | .dmypy.json 136 | dmypy.json 137 | 138 | # Pyre type checker 139 | .pyre/ 140 | 141 | # pytype static type analyzer 142 | .pytype/ 143 | 144 | # Cython debug symbols 145 | cython_debug/ 146 | 147 | # PyCharm 148 | # JetBrains specific template is maintainted in a separate JetBrains.gitignore that can 149 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 150 | # and can be added to the global gitignore or merged into this file. For a more nuclear 151 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 152 | .idea/ 153 | temp_files/* 154 | -------------------------------------------------------------------------------- /managers/complaint.py: -------------------------------------------------------------------------------- 1 | import os 2 | import uuid 3 | 4 | from constants import TEMP_FILE_FOLDER 5 | from db import database 6 | from models import complaint, RoleType, State, transaction 7 | from services.s3 import S3Service 8 | from services.ses import SESService 9 | from services.wise import WiseService 10 | from utils.helpers import decode_photo 11 | 12 | s3 = S3Service() 13 | ses = SESService() 14 | wise = WiseService() 15 | 16 | 17 | class ComplaintManager: 18 | @staticmethod 19 | async def get_complaints(user): 20 | q = complaint.select() 21 | if user["role"] == RoleType.complainer: 22 | q = q.where(complaint.c.complainer_id == user["id"]) 23 | elif user["role"] == RoleType.approver: 24 | q = q.where(complaint.c.status == State.pending) 25 | return await database.fetch_all(q) 26 | 27 | @staticmethod 28 | async def create_complaint(complaint_data, user): 29 | complaint_data["complainer_id"] = user["id"] 30 | encoded_photo = complaint_data.pop("encoded_photo") 31 | extension = complaint_data.pop("extension") 32 | name = f"{uuid.uuid4()}.{extension}" 33 | path = os.path.join(TEMP_FILE_FOLDER, name) 34 | decode_photo(path, encoded_photo) 35 | complaint_data["photo_url"] = s3.upload(path, name, extension) 36 | os.remove(path) 37 | async with database.transaction() as tconn: 38 | id_ = await tconn._connection.execute(complaint.insert().values(complaint_data)) 39 | await ComplaintManager.issue_transaction(tconn, complaint_data["amount"], f"{user['first_name']} {user['last_name']}", user["iban"], id_) 40 | return await database.fetch_one(complaint.select().where(complaint.c.id == id_)) 41 | 42 | @staticmethod 43 | async def delete(complaint_id): 44 | await database.execute(complaint.delete().where(complaint.c.id == complaint_id)) 45 | 46 | @staticmethod 47 | async def approve(id_): 48 | await database.execute( 49 | complaint.update() 50 | .where(complaint.c.id == id_) 51 | .values(status=State.approved) 52 | ) 53 | transaction_data = await database.fetch_one(transaction.select().where(transaction.c.complaint_id == id_)) 54 | wise.fund_transfer(transaction_data["transfer_id"]) 55 | ses.send_mail( 56 | "Complaint approved!", 57 | ["ines.iv.ivanova@gmail.com"], 58 | "Congrats! Your claim is approved, check your bank account in 2 days for your refund", 59 | ) 60 | 61 | @staticmethod 62 | async def reject(id_): 63 | transaction_data = await database.fetch_one(transaction.select().where(transaction.c.complaint_id == id_)) 64 | wise.cancel_funds(transaction_data["transfer_id"]) 65 | await database.execute( 66 | complaint.update() 67 | .where(complaint.c.id == id_) 68 | .values(status=State.rejected) 69 | ) 70 | 71 | @staticmethod 72 | async def issue_transaction(tconn, amount, full_name, iban, complaint_id): 73 | quote_id = wise.create_quote(amount) 74 | recipient_id = wise.create_recipient_account(full_name, iban) 75 | transfer_id = wise.create_transfer(recipient_id, quote_id) 76 | data = { 77 | "quote_id": quote_id, 78 | "transfer_id": transfer_id, 79 | "target_account_id": str(recipient_id), 80 | "amount": amount, 81 | "complaint_id": complaint_id 82 | } 83 | await tconn._connection.execute(transaction.insert().values(**data)) 84 | 85 | -------------------------------------------------------------------------------- /services/wise.py: -------------------------------------------------------------------------------- 1 | import json 2 | import uuid 3 | 4 | import requests 5 | from decouple import config 6 | from fastapi import HTTPException 7 | 8 | 9 | class WiseService: 10 | def __init__(self): 11 | self.main_url = config("WISE_URL") 12 | self.headers = { 13 | "Content-Type": "application/json", 14 | "Authorization": f"Bearer {config('WISE_TOKEN')}", 15 | } 16 | self.profile_id = self._get_profile_id() 17 | 18 | def _get_profile_id(self): 19 | url = self.main_url + "/v1/profiles" 20 | resp = requests.get(url, headers=self.headers) 21 | 22 | if resp.status_code == 200: 23 | resp = resp.json() 24 | return [el["id"] for el in resp if el["type"] == "personal"][0] 25 | print(resp) 26 | raise HTTPException(500, "Payment provider is not available at the moment") 27 | 28 | def create_quote(self, amount): 29 | url = self.main_url + "/v2/quotes" 30 | data = { 31 | "sourceCurrency": "EUR", 32 | "targetCurrency": "EUR", 33 | "sourceAmount": amount, 34 | "profile": self.profile_id, 35 | } 36 | 37 | resp = requests.post(url, headers=self.headers, data=json.dumps(data)) 38 | 39 | if resp.status_code == 200: 40 | resp = resp.json() 41 | return resp["id"] 42 | print(resp) 43 | raise HTTPException(500, "Payment provider is not available at the moment") 44 | 45 | def create_recipient_account(self, full_name, iban): 46 | url = self.main_url + "/v1/accounts" 47 | data = { 48 | "currency": "EUR", 49 | "type": "iban", 50 | "profile": self.profile_id, 51 | "accountHolderName": full_name, 52 | "legalType": "PRIVATE", 53 | "details": {"iban": iban}, 54 | } 55 | 56 | resp = requests.post(url, headers=self.headers, data=json.dumps(data)) 57 | if resp.status_code == 200: 58 | resp = resp.json() 59 | return resp["id"] 60 | print(resp) 61 | raise HTTPException(500, "Payment provider is not available at the moment") 62 | 63 | def create_transfer(self, target_account_id, quote_id): 64 | customer_transaction_id = str(uuid.uuid4()) 65 | url = self.main_url + "/v1/transfers" 66 | data = { 67 | "targetAccount": target_account_id, 68 | "quoteUuid": quote_id, 69 | "customerTransactionId": customer_transaction_id, 70 | } 71 | resp = requests.post(url, headers=self.headers, data=json.dumps(data)) 72 | 73 | if resp.status_code == 200: 74 | resp = resp.json() 75 | return resp["id"] 76 | print(resp) 77 | raise HTTPException(500, "Payment provider is not available at the moment") 78 | 79 | def fund_transfer(self, transfer_id): 80 | url = self.main_url + f"/v3/profiles/{self.profile_id}/transfers/{transfer_id}/payments" 81 | data = { 82 | "type": "BALANCE" 83 | } 84 | 85 | resp = requests.post(url, headers=self.headers, data=json.dumps(data)) 86 | 87 | if resp.status_code == 201: 88 | resp = resp.json() 89 | return resp 90 | print(resp) 91 | raise HTTPException(500, "Payment provider is not available at the moment") 92 | 93 | def cancel_funds(self, transfer_id): 94 | url = self.main_url + f"/v1/transfers/{transfer_id}/cancel" 95 | resp = requests.put(url, headers=self.headers) 96 | 97 | if resp.status_code == 200: 98 | resp = resp.json() 99 | return resp["id"] 100 | print(resp) 101 | raise HTTPException(500, "Payment provider is not available at the moment") 102 | --------------------------------------------------------------------------------