Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,29 @@ ENV=dev # dev | prod | demo
DATABASE_URL=postgresql+psycopg2://evsy:evsy@db:5432/evsy
FRONTEND_URL=http://localhost:3000


# Frontend
VITE_ENV=dev # dev | prod | demo
VITE_API_URL=http://localhost:8000/api/v1
VITE_LOG_LEVEL=error
__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS=demo.evsy.dev
__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS=demo.evsy.dev


# Auth

## Secret key for signing JWTs. Use a secure 32+ character string.
SECRET_KEY=YOUR_32_CHAR_SECRET_KEY

## GitHub OAuth credentials
## Create your app here: https://github.com/settings/developers
## Set "Authorization callback URL" to:
## http://localhost:8000/api/v1/auth/oauth/callback
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=

## Google OAuth credentials
## Create credentials here: https://console.cloud.google.com/apis/credentials
## Set "Authorized redirect URI" to:
## http://localhost:8000/api/v1/auth/oauth/callback
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
6 changes: 3 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ up:
down:
docker compose down

migrate: # e. g. make revision name="add auth"
migrate:
docker compose exec backend alembic upgrade head

revision:
docker compose exec backend alembic revision -m "$(name)"
revision: # e. g. make revision name="add auth"
docker compose exec backend alembic revision --autogenerate -m "$(name)"

dev:
docker compose -f docker-compose.dev.yaml up --build -d
3 changes: 0 additions & 3 deletions backend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,6 @@ __pycache__/
env/
venv/

# Alembic
migrations/versions/

# Database files
*.sqlite3
*.db
Expand Down
5 changes: 3 additions & 2 deletions backend/app/api/v1/routes/admin.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
from fastapi import APIRouter
from fastapi import APIRouter, Depends

from app.modules.admin.io.router import router as io_router
from app.modules.admin.reset.router import router as reset_router
from app.modules.admin.seed.router import router as seed_router
from app.modules.auth.token import get_current_user

router = APIRouter(prefix="/admin")
router = APIRouter(prefix="/admin", dependencies=[Depends(get_current_user)])

router.include_router(io_router)
router.include_router(seed_router)
Expand Down
7 changes: 7 additions & 0 deletions backend/app/api/v1/routes/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from fastapi import APIRouter

from app.modules.auth.router import router as auth_router

router = APIRouter(prefix="/auth", tags=["auth"])

router.include_router(auth_router)
5 changes: 4 additions & 1 deletion backend/app/api/v1/routes/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,16 @@
from sqlalchemy.orm import Session

from app.core.database import get_db
from app.modules.auth.token import get_current_user
from app.modules.events import crud as event_crud
from app.modules.events.schemas import EventCreate, EventOut
from app.modules.events.service import generate_json_schema_for_event
from app.modules.fields.crud import get_fields_by_ids
from app.modules.tags.crud import get_or_create_tags

router = APIRouter(prefix="/events", tags=["events"])
router = APIRouter(
prefix="/events", tags=["events"], dependencies=[Depends(get_current_user)]
)


@router.post(
Expand Down
5 changes: 4 additions & 1 deletion backend/app/api/v1/routes/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@
from sqlalchemy.orm import Session

from app.core.database import get_db
from app.modules.auth.token import get_current_user
from app.modules.fields import crud as field_crud
from app.modules.fields.schemas import FieldCreate, FieldOut, FieldOutWithEventCount

router = APIRouter(prefix="/fields", tags=["fields"])
router = APIRouter(
prefix="/fields", tags=["fields"], dependencies=[Depends(get_current_user)]
)


@router.post(
Expand Down
5 changes: 4 additions & 1 deletion backend/app/api/v1/routes/tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@
from sqlalchemy.orm import Session

from app.core.database import get_db
from app.modules.auth.token import get_current_user
from app.modules.tags import crud as tag_crud
from app.modules.tags.schemas import TagCreate, TagOut

router = APIRouter(prefix="/tags", tags=["tags"])
router = APIRouter(
prefix="/tags", tags=["tags"], dependencies=[Depends(get_current_user)]
)


@router.post(
Expand Down
17 changes: 17 additions & 0 deletions backend/app/core/guard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from fastapi import Depends, HTTPException

from app.settings import get_settings


def ensure_not_demo(settings=Depends(get_settings)):
if settings.is_demo:
raise HTTPException(
status_code=403, detail="This action is not allowed in demo mode."
)


def ensure_dev(settings=Depends(get_settings)):
if not settings.is_dev:
raise HTTPException(
status_code=403, detail="This action is allowed only in development."
)
Empty file removed backend/app/core/security.py
Empty file.
21 changes: 19 additions & 2 deletions backend/app/factory.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,26 @@
from contextlib import asynccontextmanager

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.orm import sessionmaker

from app.api.v1.routes import admin, events, fields, generic, tags
from app.api.v1.routes import admin, auth, events, fields, generic, tags
from app.modules.auth.schemas import UserCreate
from app.modules.auth.service import create_user_if_not_exists
from app.settings import Settings


def create_app(settings: Settings) -> FastAPI:
def create_app(settings: Settings, SessionLocal: sessionmaker) -> FastAPI:

@asynccontextmanager
async def lifespan(app: FastAPI):
if settings.is_demo:
with SessionLocal() as db:
create_user_if_not_exists(
db, UserCreate(email="demo@evsy.dev", password="bestructured")
)
yield

app = FastAPI(
title="Evsy API",
description="Evsy is a service for managing and tracking product events.",
Expand All @@ -30,6 +45,7 @@ def create_app(settings: Settings) -> FastAPI:
],
debug=settings.is_dev,
root_path="/api",
lifespan=lifespan,
)

app.state.settings = settings
Expand Down Expand Up @@ -59,6 +75,7 @@ def create_app(settings: Settings) -> FastAPI:
app.include_router(fields.router, prefix="/v1", tags=["fields"])
app.include_router(generic.router, prefix="/v1", tags=["generic"])
app.include_router(admin.router, prefix="/v1", tags=["admin"])
app.include_router(auth.router, prefix="/v1", tags=["auth"])

@app.get("/")
def read_root():
Expand Down
2 changes: 1 addition & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@
sys.exit(1)

engine, SessionLocal = init_db(settings)
app = create_app(settings)
app = create_app(settings, SessionLocal)
2 changes: 2 additions & 0 deletions backend/app/modules/admin/io/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from sqlalchemy.orm import Session

from app.core.database import get_db
from app.core.guard import ensure_not_demo

from .schemas import ExportBundle
from .service import ExportTarget, ImportSource, export_to, import_from
Expand Down Expand Up @@ -48,6 +49,7 @@ def export_data(
405: {"description": "Import not allowed on non-empty database"},
501: {"description": "Import method not implemented"},
},
dependencies=[Depends(ensure_not_demo)],
)
async def import_data(
request: Request,
Expand Down
2 changes: 2 additions & 0 deletions backend/app/modules/admin/reset/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from sqlalchemy.orm import Session

from app.core.database import get_db
from app.core.guard import ensure_not_demo

from .service import count_entities, reset_database

Expand All @@ -19,6 +20,7 @@
responses={
200: {"description": "Reset performed or dry-run preview returned"},
},
dependencies=[Depends(ensure_not_demo)],
)
def reset_all_data(
dry_run: bool = Query(
Expand Down
2 changes: 2 additions & 0 deletions backend/app/modules/admin/seed/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from sqlalchemy.orm import Session

from app.core.database import get_db
from app.core.guard import ensure_not_demo

from .service import seed_all

Expand All @@ -17,6 +18,7 @@
201: {"description": "Seeding completed successfully"},
405: {"description": "Seeding not allowed on non-empty database"},
},
dependencies=[Depends(ensure_not_demo)],
)
def seed_data(db: Session = Depends(get_db)):
seed_all(db)
Expand Down
29 changes: 29 additions & 0 deletions backend/app/modules/auth/crud.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from sqlalchemy.orm import Session

from app.modules.auth.models import User


def get_user_by_email(db: Session, email: str) -> User | None:
return db.query(User).filter(User.email == email).first()


def create_user(db: Session, *, email: str, hashed_pw: str) -> User:
user = User(email=email, hashed_password=hashed_pw, is_oauth=False)
db.add(user)
db.commit()
db.refresh(user)
return user


def get_or_create_oauth_user(db: Session, email: str, provider: str) -> User:
user = get_user_by_email(db, email)
if user:
user.oauth_provider = provider
db.commit()
db.refresh(user)
return user
user = User(email=email, is_oauth=True, oauth_provider=provider)
db.add(user)
db.commit()
db.refresh(user)
return user
14 changes: 14 additions & 0 deletions backend/app/modules/auth/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from sqlalchemy import Boolean, Column, Integer, String, UniqueConstraint

from app.core.database import Base


class User(Base):
__tablename__ = "users"
__table_args__ = (UniqueConstraint("email", name="uq_user_email"),)

id = Column(Integer, primary_key=True, index=True)
email = Column(String, nullable=False, unique=True)
hashed_password = Column(String, nullable=True)
is_oauth = Column(Boolean, default=False)
oauth_provider = Column(String, nullable=True)
Loading