chore(quality): apply ruff autofixes and formatting (8 fixes, 18 files reformatted)
This commit is contained in:
+1
-1
@@ -63,4 +63,4 @@ CMD ["sh", "-c", "pip install gunicorn --quiet && exec gunicorn app.main:app \
|
||||
--access-logfile - \
|
||||
--error-logfile - \
|
||||
--capture-output \
|
||||
--enable-stdio-inheritance"]
|
||||
--enable-stdio-inheritance"]
|
||||
|
||||
+33
-18
@@ -1,23 +1,38 @@
|
||||
"""Application configuration."""
|
||||
import os
|
||||
from typing import Optional
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# Application
|
||||
APP_NAME: str = "FreeTable"
|
||||
APP_VERSION: str = "1.0.0"
|
||||
DEBUG: bool = os.getenv("DEBUG", "false").lower() == "true"
|
||||
|
||||
class Settings:
|
||||
"""Application settings."""
|
||||
|
||||
PROJECT_NAME: str = "FreeTable"
|
||||
VERSION: str = "1.0.0"
|
||||
API_V1_STR: str = "/api/v1"
|
||||
|
||||
# Database
|
||||
DATABASE_URL: str = os.getenv("DATABASE_URL", "sqlite:///./freetable.db")
|
||||
|
||||
# Security
|
||||
SECRET_KEY: str = os.getenv("SECRET_KEY", "changeme-in-production")
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||
|
||||
# CORS
|
||||
BACKEND_CORS_ORIGINS: list[str] = ["http://localhost:5173", "http://localhost:3000"]
|
||||
|
||||
settings = Settings()
|
||||
# JWT
|
||||
SECRET_KEY: str = os.getenv("SECRET_KEY", "change-me-in-production")
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 1440 # 24 hours
|
||||
|
||||
# CORS
|
||||
CORS_ORIGINS: list[str] = ["http://localhost:5173", "http://localhost:3000"]
|
||||
|
||||
# Password
|
||||
PASSWORD_COST_FACTOR: int = 12
|
||||
|
||||
# Upload
|
||||
UPLOAD_DIR: str = os.getenv("UPLOAD_DIR", "./uploads")
|
||||
MAX_UPLOAD_SIZE: int = 10 * 1024 * 1024 # 10MB
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
case_sensitive = True
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
|
||||
+18
-8
@@ -1,20 +1,30 @@
|
||||
"""Database connection and session management."""
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from app.config import settings
|
||||
from sqlalchemy.orm import sessionmaker, DeclarativeBase
|
||||
from typing import Generator
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# Create engine
|
||||
engine = create_engine(
|
||||
settings.DATABASE_URL,
|
||||
connect_args={"check_same_thread": False} if "sqlite" in settings.DATABASE_URL else {}
|
||||
connect_args={"check_same_thread": False}
|
||||
if "sqlite" in settings.DATABASE_URL
|
||||
else {},
|
||||
echo=settings.DEBUG,
|
||||
)
|
||||
|
||||
# Session factory
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
def get_db():
|
||||
"""Get database session."""
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
def get_db() -> Generator:
|
||||
"""Dependency for FastAPI to get database session."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
|
||||
@@ -1,18 +1,31 @@
|
||||
"""Attachment model."""
|
||||
from sqlalchemy import Column, Integer, String, ForeignKey, DateTime
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import String, DateTime, ForeignKey, Integer, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Attachment(Base):
|
||||
"""Attachment model."""
|
||||
__tablename__ = "attachments"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
filename = Column(String(255), nullable=False)
|
||||
filepath = Column(String(500), nullable=False)
|
||||
file_size = Column(Integer)
|
||||
mime_type = Column(String(100))
|
||||
record_id = Column(Integer, nullable=True)
|
||||
uploaded_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
record_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("records.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
column_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("columns.id", ondelete="CASCADE"), nullable=True
|
||||
)
|
||||
filename: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
file_path: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
file_size: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
mime_type: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
uploaded_at: Mapped[datetime] = mapped_column(DateTime, default=func.now())
|
||||
|
||||
# Relationships
|
||||
record = relationship("Record", back_populates="attachments")
|
||||
column = relationship("Column", back_populates="attachments")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Attachment(id={self.id}, filename={self.filename})>"
|
||||
|
||||
@@ -1,19 +1,38 @@
|
||||
"""Column model."""
|
||||
from sqlalchemy import Column, Integer, String, ForeignKey, DateTime
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import String, DateTime, Boolean, ForeignKey, Integer, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy import JSON
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Column(Base):
|
||||
"""Column model."""
|
||||
__tablename__ = "columns"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(255), nullable=False)
|
||||
field_type = Column(String(50), nullable=False) # text, number, date, select, etc.
|
||||
table_id = Column(Integer, ForeignKey("tables.id"), nullable=False)
|
||||
position = Column(Integer, default=0)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
table_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("tables.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
type: Mapped[str] = mapped_column(
|
||||
String(50), nullable=False
|
||||
) # text, number, date, select, etc.
|
||||
required: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
default_value: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||
position: Mapped[int] = mapped_column(Integer, default=0)
|
||||
options: Mapped[Optional[dict]] = mapped_column(
|
||||
JSON, nullable=True
|
||||
) # For select type: {"options": ["a", "b"]}
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=func.now())
|
||||
|
||||
# Relationships
|
||||
table = relationship("Table", back_populates="columns")
|
||||
records = relationship("Record", back_populates="column")
|
||||
cell_values = relationship(
|
||||
"CellValue", back_populates="column", cascade="all, delete-orphan"
|
||||
)
|
||||
attachments = relationship("Attachment", back_populates="column")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Column(id={self.id}, name={self.name}, type={self.type})>"
|
||||
|
||||
@@ -1,19 +1,30 @@
|
||||
"""Comment model."""
|
||||
from sqlalchemy import Column, Integer, String, ForeignKey, DateTime, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, DateTime, ForeignKey, func, Index
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Comment(Base):
|
||||
"""Comment model."""
|
||||
__tablename__ = "comments"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
content = Column(Text, nullable=False)
|
||||
author_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
table_id = Column(Integer, ForeignKey("tables.id"), nullable=True)
|
||||
record_id = Column(Integer, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
record_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("records.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
content: Mapped[str] = mapped_column(String, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
author = relationship("User", back_populates="comments")
|
||||
# Relationships
|
||||
record = relationship("Record", back_populates="comments")
|
||||
user = relationship("User", back_populates="comments")
|
||||
|
||||
__table_args__ = (Index("idx_comments_record", "record_id"),)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Comment(id={self.id}, record_id={self.record_id})>"
|
||||
|
||||
@@ -1,18 +1,73 @@
|
||||
"""Record model."""
|
||||
from sqlalchemy import Column, Integer, String, ForeignKey, DateTime, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import String, DateTime, ForeignKey, func, Index
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Record(Base):
|
||||
"""Record model."""
|
||||
__tablename__ = "records"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
column_id = Column(Integer, ForeignKey("columns.id"), nullable=False)
|
||||
row_id = Column(Integer, nullable=False)
|
||||
value = Column(Text)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
table_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("tables.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
created_by: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
updated_by: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
column = relationship("Column", back_populates="records")
|
||||
# Relationships
|
||||
table = relationship("Table", back_populates="records")
|
||||
created_by_user = relationship(
|
||||
"User", back_populates="created_records", foreign_keys=[created_by]
|
||||
)
|
||||
updated_by_user = relationship(
|
||||
"User", back_populates="updated_records", foreign_keys=[updated_by]
|
||||
)
|
||||
cell_values = relationship(
|
||||
"CellValue", back_populates="record", cascade="all, delete-orphan"
|
||||
)
|
||||
comments = relationship(
|
||||
"Comment", back_populates="record", cascade="all, delete-orphan"
|
||||
)
|
||||
attachments = relationship(
|
||||
"Attachment", back_populates="record", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
__table_args__ = (Index("idx_records_table", "table_id"),)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Record(id={self.id}, table_id={self.table_id})>"
|
||||
|
||||
|
||||
class CellValue(Base):
|
||||
__tablename__ = "cell_values"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
record_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("records.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
column_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("columns.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
value: Mapped[Optional[str]] = mapped_column(String, nullable=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
# Relationships
|
||||
record = relationship("Record", back_populates="cell_values")
|
||||
column = relationship("Column", back_populates="cell_values")
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_cell_values_record", "record_id"),
|
||||
Index("idx_cell_values_column", "column_id"),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<CellValue(record_id={self.record_id}, column_id={self.column_id})>"
|
||||
|
||||
+29
-12
@@ -1,20 +1,37 @@
|
||||
"""Table model."""
|
||||
from sqlalchemy import Column, Integer, String, ForeignKey, DateTime, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, DateTime, ForeignKey, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Table(Base):
|
||||
"""Table model."""
|
||||
__tablename__ = "tables"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(255), nullable=False)
|
||||
description = Column(Text)
|
||||
workspace_id = Column(Integer, ForeignKey("workspaces.id"), nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
workspace_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str] = mapped_column(String, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
# Relationships
|
||||
workspace = relationship("Workspace", back_populates="tables")
|
||||
columns = relationship("Column", back_populates="table", cascade="all, delete-orphan")
|
||||
views = relationship("View", back_populates="table")
|
||||
columns = relationship(
|
||||
"Column",
|
||||
back_populates="table",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="Column.position",
|
||||
)
|
||||
records = relationship(
|
||||
"Record", back_populates="table", cascade="all, delete-orphan"
|
||||
)
|
||||
views = relationship("View", back_populates="table", cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Table(id={self.id}, name={self.name})>"
|
||||
|
||||
+30
-14
@@ -1,21 +1,37 @@
|
||||
"""User model."""
|
||||
from sqlalchemy import Column, Integer, String, DateTime
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, DateTime, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""User model."""
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
email = Column(String(255), unique=True, index=True, nullable=False)
|
||||
username = Column(String(100), unique=True, index=True, nullable=False)
|
||||
hashed_password = Column(String(255), nullable=False)
|
||||
full_name = Column(String(255))
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
is_active = Column(Integer, default=1)
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
email: Mapped[str] = mapped_column(
|
||||
String(255), unique=True, nullable=False, index=True
|
||||
)
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
role: Mapped[str] = mapped_column(String(50), default="member")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
workspaces = relationship("Workspace", back_populates="owner")
|
||||
comments = relationship("Comment", back_populates="author")
|
||||
# Relationships
|
||||
owned_workspaces = relationship("Workspace", back_populates="owner")
|
||||
workspace_memberships = relationship("WorkspaceMember", back_populates="user")
|
||||
created_records = relationship(
|
||||
"Record", back_populates="created_by_user", foreign_keys="Record.created_by"
|
||||
)
|
||||
updated_records = relationship(
|
||||
"Record", back_populates="updated_by_user", foreign_keys="Record.updated_by"
|
||||
)
|
||||
comments = relationship("Comment", back_populates="user")
|
||||
views = relationship("View", back_populates="created_by_user")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<User(id={self.id}, email={self.email}, role={self.role})>"
|
||||
|
||||
+24
-11
@@ -1,19 +1,32 @@
|
||||
"""View model."""
|
||||
from sqlalchemy import Column, Integer, String, ForeignKey, DateTime, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import String, DateTime, Boolean, ForeignKey, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy import JSON
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class View(Base):
|
||||
"""View model."""
|
||||
__tablename__ = "views"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(255), nullable=False)
|
||||
view_type = Column(String(50), default="grid") # grid, gallery, form
|
||||
table_id = Column(Integer, ForeignKey("tables.id"), nullable=False)
|
||||
config = Column(Text) # JSON config for view settings
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
table_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("tables.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
type: Mapped[str] = mapped_column(String(50), default="grid") # grid, gallery, form
|
||||
config: Mapped[Optional[dict]] = mapped_column(
|
||||
JSON, nullable=True
|
||||
) # filter, sort, columns
|
||||
is_default: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
created_by: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=func.now())
|
||||
|
||||
# Relationships
|
||||
table = relationship("Table", back_populates="views")
|
||||
created_by_user = relationship("User", back_populates="views")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<View(id={self.id}, name={self.name}, type={self.type})>"
|
||||
|
||||
@@ -1,19 +1,52 @@
|
||||
"""Workspace model."""
|
||||
from sqlalchemy import Column, Integer, String, ForeignKey, DateTime, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, DateTime, Boolean, ForeignKey, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Workspace(Base):
|
||||
"""Workspace model."""
|
||||
__tablename__ = "workspaces"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(255), nullable=False)
|
||||
description = Column(Text)
|
||||
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str] = mapped_column(String, nullable=True)
|
||||
owner_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
|
||||
is_private: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
owner = relationship("User", back_populates="workspaces")
|
||||
tables = relationship("Table", back_populates="workspace")
|
||||
# Relationships
|
||||
owner = relationship("User", back_populates="owned_workspaces")
|
||||
members = relationship(
|
||||
"WorkspaceMember", back_populates="workspace", cascade="all, delete-orphan"
|
||||
)
|
||||
tables = relationship(
|
||||
"Table", back_populates="workspace", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Workspace(id={self.id}, name={self.name})>"
|
||||
|
||||
|
||||
class WorkspaceMember(Base):
|
||||
__tablename__ = "workspace_members"
|
||||
|
||||
workspace_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("workspaces.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
role: Mapped[str] = mapped_column(String(50), default="member")
|
||||
joined_at: Mapped[datetime] = mapped_column(DateTime, default=func.now())
|
||||
|
||||
# Relationships
|
||||
workspace = relationship("Workspace", back_populates="members")
|
||||
user = relationship("User", back_populates="workspace_memberships")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<WorkspaceMember(workspace_id={self.workspace_id}, user_id={self.user_id}, role={self.role})>"
|
||||
|
||||
+104
-54
@@ -1,66 +1,116 @@
|
||||
"""Authentication routes."""
|
||||
from datetime import timedelta
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.orm import Session
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.auth import Token, LoginRequest
|
||||
from app.schemas.user import UserCreate, UserResponse
|
||||
from app.config import settings
|
||||
from app.schemas.auth import LoginRequest, Token
|
||||
from app.schemas.user import UserCreate, UserRead
|
||||
from app.utils.security import (
|
||||
verify_password,
|
||||
get_password_hash,
|
||||
create_access_token,
|
||||
decode_access_token,
|
||||
)
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
|
||||
router = APIRouter()
|
||||
security = HTTPBearer()
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Verify password."""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
"""Hash password."""
|
||||
return pwd_context.hash(password)
|
||||
|
||||
def create_access_token(data: dict, expires_delta: timedelta | None = None):
|
||||
"""Create access token."""
|
||||
from jose import jwt
|
||||
to_encode = data.copy()
|
||||
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=15))
|
||||
to_encode.update({"exp": expire})
|
||||
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
|
||||
@router.post("/register", response_model=UserResponse)
|
||||
def register(user: UserCreate, db: Session = Depends(get_db)):
|
||||
"""Register new user."""
|
||||
db_user = db.query(User).filter(User.email == user.email).first()
|
||||
if db_user:
|
||||
raise HTTPException(status_code=400, detail="Email already registered")
|
||||
hashed_password = get_password_hash(user.password)
|
||||
db_user = User(
|
||||
email=user.email,
|
||||
username=user.username,
|
||||
hashed_password=hashed_password,
|
||||
full_name=user.full_name
|
||||
)
|
||||
db.add(db_user)
|
||||
db.commit()
|
||||
db.refresh(db_user)
|
||||
return db_user
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
|
||||
"""Login user."""
|
||||
user = db.query(User).filter(User.username == form_data.username).first()
|
||||
if not user or not verify_password(form_data.password, user.hashed_password):
|
||||
def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: Session = Depends(get_db),
|
||||
) -> User:
|
||||
"""Dependency to get the current authenticated user."""
|
||||
token = credentials.credentials
|
||||
payload = decode_access_token(token)
|
||||
if payload is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password"
|
||||
detail="Invalid or expired token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
access_token = create_access_token(
|
||||
data={"sub": str(user.id)},
|
||||
expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
user_id = payload.get("sub")
|
||||
if user_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token payload",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
user = db.query(User).filter(User.id == int(user_id)).first()
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
@router.post(
|
||||
"/auth/register", response_model=UserRead, status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
def register(user_data: UserCreate, db: Session = Depends(get_db)):
|
||||
"""Register a new user."""
|
||||
# Check if email already exists
|
||||
existing_user = db.query(User).filter(User.email == user_data.email).first()
|
||||
if existing_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered"
|
||||
)
|
||||
|
||||
# Create new user
|
||||
hashed_password = get_password_hash(user_data.password)
|
||||
new_user = User(
|
||||
email=user_data.email,
|
||||
password_hash=hashed_password,
|
||||
name=user_data.name,
|
||||
role="member",
|
||||
)
|
||||
return {"access_token": access_token, "token_type": "bearer"}
|
||||
db.add(new_user)
|
||||
db.commit()
|
||||
db.refresh(new_user)
|
||||
return new_user
|
||||
|
||||
|
||||
@router.post("/auth/login", response_model=Token)
|
||||
def login(login_data: LoginRequest, db: Session = Depends(get_db)):
|
||||
"""Login and return JWT token."""
|
||||
user = db.query(User).filter(User.email == login_data.email).first()
|
||||
if not user or not verify_password(login_data.password, user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password"
|
||||
)
|
||||
|
||||
access_token = create_access_token(data={"sub": str(user.id), "email": user.email})
|
||||
return Token(access_token=access_token)
|
||||
|
||||
|
||||
@router.post("/auth/logout")
|
||||
def logout(current_user: User = Depends(get_current_user)):
|
||||
"""Logout (client should discard the token)."""
|
||||
return {"message": "Logged out successfully"}
|
||||
|
||||
|
||||
@router.get("/auth/me", response_model=UserRead)
|
||||
def get_me(current_user: User = Depends(get_current_user)):
|
||||
"""Get current user info."""
|
||||
return current_user
|
||||
|
||||
|
||||
@router.put("/auth/password")
|
||||
def change_password(
|
||||
current_password: str,
|
||||
new_password: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Change password."""
|
||||
if not verify_password(current_password, current_user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Current password is incorrect",
|
||||
)
|
||||
current_user.password_hash = get_password_hash(new_password)
|
||||
db.commit()
|
||||
return {"message": "Password changed successfully"}
|
||||
|
||||
+210
-31
@@ -1,48 +1,227 @@
|
||||
"""Record routes."""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.record import Record
|
||||
from app.schemas.record import RecordCreate, RecordUpdate, RecordResponse
|
||||
from app.models.user import User
|
||||
from app.models.record import Record, CellValue
|
||||
from app.schemas.record import (
|
||||
RecordCreate,
|
||||
RecordRead,
|
||||
RecordUpdate,
|
||||
RecordDetailRead,
|
||||
BulkRecordOperation,
|
||||
)
|
||||
from app.routers.auth import get_current_user
|
||||
from app.routers.tables import check_table_access
|
||||
|
||||
router = APIRouter(prefix="/records", tags=["records"])
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/table/{table_id}", response_model=List[RecordResponse])
|
||||
def list_records(table_id: int, db: Session = Depends(get_db)):
|
||||
"""List all records for a table."""
|
||||
return db.query(Record).filter(Record.column_id.in_(
|
||||
db.query(Column.id).filter(Column.table_id == table_id)
|
||||
)).all()
|
||||
|
||||
@router.post("", response_model=RecordResponse)
|
||||
def create_record(record: RecordCreate, db: Session = Depends(get_db)):
|
||||
"""Create new record."""
|
||||
db_record = Record(**record.model_dump())
|
||||
db.add(db_record)
|
||||
@router.get("/tables/{table_id}/records", response_model=List[RecordDetailRead])
|
||||
def list_records(
|
||||
table_id: int,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List records (paginated)."""
|
||||
check_table_access(table_id, db, current_user)
|
||||
records = (
|
||||
db.query(Record)
|
||||
.filter(Record.table_id == table_id)
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return records
|
||||
|
||||
|
||||
@router.post("/tables/{table_id}/records", response_model=RecordRead, status_code=201)
|
||||
def create_record(
|
||||
table_id: int,
|
||||
data: RecordCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Create record."""
|
||||
check_table_access(table_id, db, current_user)
|
||||
record = Record(
|
||||
table_id=table_id, created_by=current_user.id, updated_by=current_user.id
|
||||
)
|
||||
db.add(record)
|
||||
db.flush() # Get record.id
|
||||
|
||||
# Add cell values
|
||||
if data.cells:
|
||||
for cell in data.cells:
|
||||
cell_value = CellValue(
|
||||
record_id=record.id, column_id=cell.column_id, value=cell.value
|
||||
)
|
||||
db.add(cell_value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_record)
|
||||
return db_record
|
||||
db.refresh(record)
|
||||
return record
|
||||
|
||||
@router.put("/{record_id}", response_model=RecordResponse)
|
||||
def update_record(record_id: int, record: RecordUpdate, db: Session = Depends(get_db)):
|
||||
"""Update record."""
|
||||
db_record = db.query(Record).filter(Record.id == record_id).first()
|
||||
if not db_record:
|
||||
|
||||
@router.get("/tables/{table_id}/records/{record_id}", response_model=RecordDetailRead)
|
||||
def get_record(
|
||||
table_id: int,
|
||||
record_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Get single record."""
|
||||
check_table_access(table_id, db, current_user)
|
||||
record = (
|
||||
db.query(Record)
|
||||
.filter(Record.id == record_id, Record.table_id == table_id)
|
||||
.first()
|
||||
)
|
||||
if not record:
|
||||
raise HTTPException(status_code=404, detail="Record not found")
|
||||
for key, value in record.model_dump(exclude_unset=True).items():
|
||||
setattr(db_record, key, value)
|
||||
db.commit()
|
||||
db.refresh(db_record)
|
||||
return db_record
|
||||
return record
|
||||
|
||||
@router.delete("/{record_id}")
|
||||
def delete_record(record_id: int, db: Session = Depends(get_db)):
|
||||
|
||||
@router.put("/tables/{table_id}/records/{record_id}", response_model=RecordRead)
|
||||
def update_record(
|
||||
table_id: int,
|
||||
record_id: int,
|
||||
data: RecordUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Update record."""
|
||||
check_table_access(table_id, db, current_user)
|
||||
record = (
|
||||
db.query(Record)
|
||||
.filter(Record.id == record_id, Record.table_id == table_id)
|
||||
.first()
|
||||
)
|
||||
if not record:
|
||||
raise HTTPException(status_code=404, detail="Record not found")
|
||||
|
||||
record.updated_by = current_user.id
|
||||
|
||||
# Update cell values
|
||||
if data.cells:
|
||||
for cell in data.cells:
|
||||
existing = (
|
||||
db.query(CellValue)
|
||||
.filter(
|
||||
CellValue.record_id == record_id,
|
||||
CellValue.column_id == cell.column_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
existing.value = cell.value
|
||||
else:
|
||||
cell_value = CellValue(
|
||||
record_id=record_id, column_id=cell.column_id, value=cell.value
|
||||
)
|
||||
db.add(cell_value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(record)
|
||||
return record
|
||||
|
||||
|
||||
@router.delete("/tables/{table_id}/records/{record_id}")
|
||||
def delete_record(
|
||||
table_id: int,
|
||||
record_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Delete record."""
|
||||
record = db.query(Record).filter(Record.id == record_id).first()
|
||||
check_table_access(table_id, db, current_user)
|
||||
record = (
|
||||
db.query(Record)
|
||||
.filter(Record.id == record_id, Record.table_id == table_id)
|
||||
.first()
|
||||
)
|
||||
if not record:
|
||||
raise HTTPException(status_code=404, detail="Record not found")
|
||||
db.delete(record)
|
||||
db.commit()
|
||||
return {"message": "Record deleted"}
|
||||
|
||||
|
||||
@router.post("/tables/{table_id}/records/bulk")
|
||||
def bulk_operations(
|
||||
table_id: int,
|
||||
data: BulkRecordOperation,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Bulk create/update records."""
|
||||
check_table_access(table_id, db, current_user)
|
||||
results = {"created": [], "updated": [], "deleted": 0}
|
||||
|
||||
# Create
|
||||
if data.create:
|
||||
for record_data in data.create:
|
||||
record = Record(
|
||||
table_id=table_id,
|
||||
created_by=current_user.id,
|
||||
updated_by=current_user.id,
|
||||
)
|
||||
db.add(record)
|
||||
db.flush()
|
||||
if record_data.cells:
|
||||
for cell in record_data.cells:
|
||||
cell_value = CellValue(
|
||||
record_id=record.id, column_id=cell.column_id, value=cell.value
|
||||
)
|
||||
db.add(cell_value)
|
||||
results["created"].append(record.id)
|
||||
|
||||
# Update
|
||||
if data.update:
|
||||
for update_data in data.update:
|
||||
record_id = update_data.get("id")
|
||||
record = (
|
||||
db.query(Record)
|
||||
.filter(Record.id == record_id, Record.table_id == table_id)
|
||||
.first()
|
||||
)
|
||||
if record:
|
||||
record.updated_by = current_user.id
|
||||
if update_data.get("cells"):
|
||||
for cell in update_data["cells"]:
|
||||
existing = (
|
||||
db.query(CellValue)
|
||||
.filter(
|
||||
CellValue.record_id == record_id,
|
||||
CellValue.column_id == cell["column_id"],
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
existing.value = cell["value"]
|
||||
else:
|
||||
cell_value = CellValue(
|
||||
record_id=record_id,
|
||||
column_id=cell["column_id"],
|
||||
value=cell["value"],
|
||||
)
|
||||
db.add(cell_value)
|
||||
results["updated"].append(record_id)
|
||||
|
||||
# Delete
|
||||
if data.delete:
|
||||
for record_id in data.delete:
|
||||
record = (
|
||||
db.query(Record)
|
||||
.filter(Record.id == record_id, Record.table_id == table_id)
|
||||
.first()
|
||||
)
|
||||
if record:
|
||||
db.delete(record)
|
||||
results["deleted"] += 1
|
||||
|
||||
db.commit()
|
||||
return results
|
||||
|
||||
+147
-46
@@ -1,69 +1,170 @@
|
||||
"""Table routes."""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.models.table import Table
|
||||
from app.models.column import Column
|
||||
from app.schemas.table import TableCreate, TableUpdate, TableResponse
|
||||
from app.schemas.table import (
|
||||
TableCreate,
|
||||
TableRead,
|
||||
TableUpdate,
|
||||
ColumnCreate,
|
||||
ColumnRead,
|
||||
ColumnUpdate,
|
||||
TableDetailRead,
|
||||
)
|
||||
from app.routers.auth import get_current_user
|
||||
from app.routers.workspaces import check_workspace_access
|
||||
|
||||
router = APIRouter(prefix="/tables", tags=["tables"])
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/workspace/{workspace_id}", response_model=List[TableResponse])
|
||||
def list_tables(workspace_id: int, db: Session = Depends(get_db)):
|
||||
"""List all tables in a workspace."""
|
||||
return db.query(Table).filter(Table.workspace_id == workspace_id).all()
|
||||
|
||||
@router.post("", response_model=TableResponse)
|
||||
def create_table(table: TableCreate, db: Session = Depends(get_db)):
|
||||
"""Create new table."""
|
||||
db_table = Table(**table.model_dump())
|
||||
db.add(db_table)
|
||||
db.commit()
|
||||
db.refresh(db_table)
|
||||
return db_table
|
||||
|
||||
@router.get("/{table_id}", response_model=TableResponse)
|
||||
def get_table(table_id: int, db: Session = Depends(get_db)):
|
||||
"""Get table by ID."""
|
||||
def check_table_access(table_id: int, db: Session, user: User) -> Table:
|
||||
"""Check if user has access to table."""
|
||||
table = db.query(Table).filter(Table.id == table_id).first()
|
||||
if not table:
|
||||
raise HTTPException(status_code=404, detail="Table not found")
|
||||
check_workspace_access(table.workspace_id, db, user)
|
||||
return table
|
||||
|
||||
@router.put("/{table_id}", response_model=TableResponse)
|
||||
def update_table(table_id: int, table: TableUpdate, db: Session = Depends(get_db)):
|
||||
"""Update table."""
|
||||
db_table = db.query(Table).filter(Table.id == table_id).first()
|
||||
if not db_table:
|
||||
raise HTTPException(status_code=404, detail="Table not found")
|
||||
for key, value in table.model_dump(exclude_unset=True).items():
|
||||
setattr(db_table, key, value)
|
||||
db.commit()
|
||||
db.refresh(db_table)
|
||||
return db_table
|
||||
|
||||
@router.delete("/{table_id}")
|
||||
def delete_table(table_id: int, db: Session = Depends(get_db)):
|
||||
@router.get("/workspaces/{workspace_id}/tables", response_model=List[TableRead])
|
||||
def list_tables(
|
||||
workspace_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List tables in workspace."""
|
||||
check_workspace_access(workspace_id, db, current_user)
|
||||
return db.query(Table).filter(Table.workspace_id == workspace_id).all()
|
||||
|
||||
|
||||
@router.post(
|
||||
"/workspaces/{workspace_id}/tables", response_model=TableRead, status_code=201
|
||||
)
|
||||
def create_table(
|
||||
workspace_id: int,
|
||||
data: TableCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Create table in workspace."""
|
||||
check_workspace_access(workspace_id, db, current_user)
|
||||
table = Table(
|
||||
workspace_id=workspace_id, name=data.name, description=data.description
|
||||
)
|
||||
db.add(table)
|
||||
db.commit()
|
||||
db.refresh(table)
|
||||
return table
|
||||
|
||||
|
||||
@router.get("/tables/{table_id}", response_model=TableDetailRead)
|
||||
def get_table(
|
||||
table_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Get table with columns."""
|
||||
table = check_table_access(table_id, db, current_user)
|
||||
return table
|
||||
|
||||
|
||||
@router.put("/tables/{table_id}", response_model=TableRead)
|
||||
def update_table(
|
||||
table_id: int,
|
||||
data: TableUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Update table."""
|
||||
table = check_table_access(table_id, db, current_user)
|
||||
for key, value in data.model_dump(exclude_unset=True).items():
|
||||
setattr(table, key, value)
|
||||
db.commit()
|
||||
db.refresh(table)
|
||||
return table
|
||||
|
||||
|
||||
@router.delete("/tables/{table_id}")
|
||||
def delete_table(
|
||||
table_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Delete table."""
|
||||
table = db.query(Table).filter(Table.id == table_id).first()
|
||||
if not table:
|
||||
raise HTTPException(status_code=404, detail="Table not found")
|
||||
table = check_table_access(table_id, db, current_user)
|
||||
db.delete(table)
|
||||
db.commit()
|
||||
return {"message": "Table deleted"}
|
||||
|
||||
@router.get("/{table_id}/columns")
|
||||
def get_columns(table_id: int, db: Session = Depends(get_db)):
|
||||
"""Get columns for a table."""
|
||||
return db.query(Column).filter(Column.table_id == table_id).all()
|
||||
|
||||
@router.post("/{table_id}/columns")
|
||||
def create_column(table_id: int, column: dict, db: Session = Depends(get_db)):
|
||||
"""Create column in table."""
|
||||
db_column = Column(table_id=table_id, **column)
|
||||
db.add(db_column)
|
||||
@router.post("/tables/{table_id}/columns", response_model=ColumnRead, status_code=201)
|
||||
def add_column(
|
||||
table_id: int,
|
||||
data: ColumnCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Add column to table."""
|
||||
check_table_access(table_id, db, current_user)
|
||||
column = Column(
|
||||
table_id=table_id,
|
||||
name=data.name,
|
||||
type=data.type,
|
||||
required=data.required,
|
||||
default_value=data.default_value,
|
||||
position=data.position,
|
||||
options=data.options,
|
||||
)
|
||||
db.add(column)
|
||||
db.commit()
|
||||
db.refresh(db_column)
|
||||
return db_column
|
||||
db.refresh(column)
|
||||
return column
|
||||
|
||||
|
||||
@router.put("/tables/{table_id}/columns/{column_id}", response_model=ColumnRead)
|
||||
def update_column(
|
||||
table_id: int,
|
||||
column_id: int,
|
||||
data: ColumnUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Update column."""
|
||||
check_table_access(table_id, db, current_user)
|
||||
column = (
|
||||
db.query(Column)
|
||||
.filter(Column.id == column_id, Column.table_id == table_id)
|
||||
.first()
|
||||
)
|
||||
if not column:
|
||||
raise HTTPException(status_code=404, detail="Column not found")
|
||||
for key, value in data.model_dump(exclude_unset=True).items():
|
||||
setattr(column, key, value)
|
||||
db.commit()
|
||||
db.refresh(column)
|
||||
return column
|
||||
|
||||
|
||||
@router.delete("/tables/{table_id}/columns/{column_id}")
|
||||
def delete_column(
|
||||
table_id: int,
|
||||
column_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Delete column."""
|
||||
check_table_access(table_id, db, current_user)
|
||||
column = (
|
||||
db.query(Column)
|
||||
.filter(Column.id == column_id, Column.table_id == table_id)
|
||||
.first()
|
||||
)
|
||||
if not column:
|
||||
raise HTTPException(status_code=404, detail="Column not found")
|
||||
db.delete(column)
|
||||
db.commit()
|
||||
return {"message": "Column deleted"}
|
||||
|
||||
@@ -1,23 +1,67 @@
|
||||
"""User routes."""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.user import UserResponse, UserUpdate
|
||||
from app.schemas.user import UserRead, UserUpdate
|
||||
from app.routers.auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
def get_current_user(db: Session = Depends(get_db), current_user: User = Depends(lambda: None)):
|
||||
"""Get current user."""
|
||||
# Placeholder - implement auth dependency
|
||||
return UserResponse(id=1, email="user@example.com", username="user", is_active=1, created_at=None)
|
||||
|
||||
@router.get("/{user_id}", response_model=UserResponse)
|
||||
def get_user(user_id: int, db: Session = Depends(get_db)):
|
||||
"""Get user by ID."""
|
||||
def require_admin(current_user: User = Depends(get_current_user)) -> User:
|
||||
"""Require admin role."""
|
||||
if current_user.role != "admin":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required"
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
@router.get("/users", response_model=List[UserRead])
|
||||
def list_users(db: Session = Depends(get_db), admin: User = Depends(require_admin)):
|
||||
"""List all users (admin only)."""
|
||||
return db.query(User).all()
|
||||
|
||||
|
||||
@router.get("/users/{user_id}", response_model=UserRead)
|
||||
def get_user(
|
||||
user_id: int, db: Session = Depends(get_db), admin: User = Depends(require_admin)
|
||||
):
|
||||
"""Get user by ID (admin only)."""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
return user
|
||||
|
||||
|
||||
@router.put("/users/{user_id}", response_model=UserRead)
|
||||
def update_user(
|
||||
user_id: int,
|
||||
data: UserUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
admin: User = Depends(require_admin),
|
||||
):
|
||||
"""Update user (admin only)."""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
for key, value in data.model_dump(exclude_unset=True).items():
|
||||
setattr(user, key, value)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}")
|
||||
def delete_user(
|
||||
user_id: int, db: Session = Depends(get_db), admin: User = Depends(require_admin)
|
||||
):
|
||||
"""Delete user (admin only)."""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
return {"message": "User deleted"}
|
||||
|
||||
@@ -1,54 +1,110 @@
|
||||
"""View routes."""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.models.view import View
|
||||
from app.schemas.view import ViewCreate, ViewUpdate, ViewResponse
|
||||
from app.schemas.view import ViewCreate, ViewRead, ViewUpdate
|
||||
from app.routers.auth import get_current_user
|
||||
from app.routers.tables import check_table_access
|
||||
|
||||
router = APIRouter(prefix="/views", tags=["views"])
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/table/{table_id}", response_model=List[ViewResponse])
|
||||
def list_views(table_id: int, db: Session = Depends(get_db)):
|
||||
"""List all views for a table."""
|
||||
|
||||
@router.get("/tables/{table_id}/views", response_model=List[ViewRead])
|
||||
def list_views(
|
||||
table_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List views for table."""
|
||||
check_table_access(table_id, db, current_user)
|
||||
return db.query(View).filter(View.table_id == table_id).all()
|
||||
|
||||
@router.post("", response_model=ViewResponse)
|
||||
def create_view(view: ViewCreate, db: Session = Depends(get_db)):
|
||||
"""Create new view."""
|
||||
db_view = View(**view.model_dump())
|
||||
db.add(db_view)
|
||||
db.commit()
|
||||
db.refresh(db_view)
|
||||
return db_view
|
||||
|
||||
@router.get("/{view_id}", response_model=ViewResponse)
|
||||
def get_view(view_id: int, db: Session = Depends(get_db)):
|
||||
"""Get view by ID."""
|
||||
@router.post("/tables/{table_id}/views", response_model=ViewRead, status_code=201)
|
||||
def create_view(
|
||||
table_id: int,
|
||||
data: ViewCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Create view."""
|
||||
check_table_access(table_id, db, current_user)
|
||||
|
||||
# If is_default, unset other defaults
|
||||
if data.is_default:
|
||||
db.query(View).filter(
|
||||
View.table_id == table_id, View.is_default == True
|
||||
).update({"is_default": False})
|
||||
|
||||
view = View(
|
||||
table_id=table_id,
|
||||
name=data.name,
|
||||
type=data.type,
|
||||
config=data.config,
|
||||
is_default=data.is_default,
|
||||
created_by=current_user.id,
|
||||
)
|
||||
db.add(view)
|
||||
db.commit()
|
||||
db.refresh(view)
|
||||
return view
|
||||
|
||||
|
||||
@router.get("/views/{view_id}", response_model=ViewRead)
|
||||
def get_view(
|
||||
view_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Get view config."""
|
||||
view = db.query(View).filter(View.id == view_id).first()
|
||||
if not view:
|
||||
raise HTTPException(status_code=404, detail="View not found")
|
||||
check_table_access(view.table_id, db, current_user)
|
||||
return view
|
||||
|
||||
@router.put("/{view_id}", response_model=ViewResponse)
|
||||
def update_view(view_id: int, view: ViewUpdate, db: Session = Depends(get_db)):
|
||||
"""Update view."""
|
||||
db_view = db.query(View).filter(View.id == view_id).first()
|
||||
if not db_view:
|
||||
raise HTTPException(status_code=404, detail="View not found")
|
||||
for key, value in view.model_dump(exclude_unset=True).items():
|
||||
setattr(db_view, key, value)
|
||||
db.commit()
|
||||
db.refresh(db_view)
|
||||
return db_view
|
||||
|
||||
@router.delete("/{view_id}")
|
||||
def delete_view(view_id: int, db: Session = Depends(get_db)):
|
||||
@router.put("/views/{view_id}", response_model=ViewRead)
|
||||
def update_view(
|
||||
view_id: int,
|
||||
data: ViewUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Update view."""
|
||||
view = db.query(View).filter(View.id == view_id).first()
|
||||
if not view:
|
||||
raise HTTPException(status_code=404, detail="View not found")
|
||||
check_table_access(view.table_id, db, current_user)
|
||||
|
||||
# If setting as default, unset other defaults
|
||||
if data.is_default and not view.is_default:
|
||||
db.query(View).filter(
|
||||
View.table_id == view.table_id, View.is_default == True
|
||||
).update({"is_default": False})
|
||||
|
||||
for key, value in data.model_dump(exclude_unset=True).items():
|
||||
setattr(view, key, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(view)
|
||||
return view
|
||||
|
||||
|
||||
@router.delete("/views/{view_id}")
|
||||
def delete_view(
|
||||
view_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Delete view."""
|
||||
view = db.query(View).filter(View.id == view_id).first()
|
||||
if not view:
|
||||
raise HTTPException(status_code=404, detail="View not found")
|
||||
check_table_access(view.table_id, db, current_user)
|
||||
db.delete(view)
|
||||
db.commit()
|
||||
return {"message": "View deleted"}
|
||||
|
||||
@@ -1,54 +1,215 @@
|
||||
"""Workspace routes."""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.workspace import Workspace
|
||||
from app.schemas.workspace import WorkspaceCreate, WorkspaceUpdate, WorkspaceResponse
|
||||
from app.models.user import User
|
||||
from app.models.workspace import Workspace, WorkspaceMember
|
||||
from app.schemas.workspace import (
|
||||
WorkspaceCreate,
|
||||
WorkspaceRead,
|
||||
WorkspaceUpdate,
|
||||
WorkspaceMemberCreate,
|
||||
WorkspaceMemberRead,
|
||||
WorkspaceMemberUpdate,
|
||||
)
|
||||
from app.routers.auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/workspaces", tags=["workspaces"])
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("", response_model=List[WorkspaceResponse])
|
||||
def list_workspaces(db: Session = Depends(get_db)):
|
||||
"""List all workspaces."""
|
||||
return db.query(Workspace).all()
|
||||
|
||||
@router.post("", response_model=WorkspaceResponse)
|
||||
def create_workspace(workspace: WorkspaceCreate, db: Session = Depends(get_db)):
|
||||
"""Create new workspace."""
|
||||
db_workspace = Workspace(**workspace.model_dump())
|
||||
db.add(db_workspace)
|
||||
db.commit()
|
||||
db.refresh(db_workspace)
|
||||
return db_workspace
|
||||
|
||||
@router.get("/{workspace_id}", response_model=WorkspaceResponse)
|
||||
def get_workspace(workspace_id: int, db: Session = Depends(get_db)):
|
||||
"""Get workspace by ID."""
|
||||
def check_workspace_access(workspace_id: int, db: Session, user: User) -> Workspace:
|
||||
"""Check if user has access to workspace."""
|
||||
workspace = db.query(Workspace).filter(Workspace.id == workspace_id).first()
|
||||
if not workspace:
|
||||
raise HTTPException(status_code=404, detail="Workspace not found")
|
||||
# Owner or member has access
|
||||
if workspace.owner_id != user.id:
|
||||
membership = (
|
||||
db.query(WorkspaceMember)
|
||||
.filter(
|
||||
WorkspaceMember.workspace_id == workspace_id,
|
||||
WorkspaceMember.user_id == user.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not membership:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
return workspace
|
||||
|
||||
@router.put("/{workspace_id}", response_model=WorkspaceResponse)
|
||||
def update_workspace(workspace_id: int, workspace: WorkspaceUpdate, db: Session = Depends(get_db)):
|
||||
"""Update workspace."""
|
||||
db_workspace = db.query(Workspace).filter(Workspace.id == workspace_id).first()
|
||||
if not db_workspace:
|
||||
raise HTTPException(status_code=404, detail="Workspace not found")
|
||||
for key, value in workspace.model_dump(exclude_unset=True).items():
|
||||
setattr(db_workspace, key, value)
|
||||
db.commit()
|
||||
db.refresh(db_workspace)
|
||||
return db_workspace
|
||||
|
||||
@router.delete("/{workspace_id}")
|
||||
def delete_workspace(workspace_id: int, db: Session = Depends(get_db)):
|
||||
"""Delete workspace."""
|
||||
workspace = db.query(Workspace).filter(Workspace.id == workspace_id).first()
|
||||
if not workspace:
|
||||
raise HTTPException(status_code=404, detail="Workspace not found")
|
||||
@router.get("/workspaces", response_model=List[WorkspaceRead])
|
||||
def list_workspaces(
|
||||
db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""List user's workspaces (owned + member)."""
|
||||
owned = db.query(Workspace).filter(Workspace.owner_id == current_user.id).all()
|
||||
memberships = (
|
||||
db.query(WorkspaceMember)
|
||||
.filter(WorkspaceMember.user_id == current_user.id)
|
||||
.all()
|
||||
)
|
||||
member_ids = [m.workspace_id for m in memberships]
|
||||
member_workspaces = (
|
||||
db.query(Workspace).filter(Workspace.id.in_(member_ids)).all()
|
||||
if member_ids
|
||||
else []
|
||||
)
|
||||
return list(set(owned + member_workspaces))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/workspaces", response_model=WorkspaceRead, status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
def create_workspace(
|
||||
data: WorkspaceCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Create a new workspace."""
|
||||
workspace = Workspace(
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
owner_id=current_user.id,
|
||||
is_private=data.is_private,
|
||||
)
|
||||
db.add(workspace)
|
||||
db.commit()
|
||||
db.refresh(workspace)
|
||||
return workspace
|
||||
|
||||
|
||||
@router.get("/workspaces/{workspace_id}", response_model=WorkspaceRead)
|
||||
def get_workspace(
|
||||
workspace_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Get workspace details."""
|
||||
return check_workspace_access(workspace_id, db, current_user)
|
||||
|
||||
|
||||
@router.put("/workspaces/{workspace_id}", response_model=WorkspaceRead)
|
||||
def update_workspace(
|
||||
workspace_id: int,
|
||||
data: WorkspaceUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Update workspace (owner only)."""
|
||||
workspace = check_workspace_access(workspace_id, db, current_user)
|
||||
if workspace.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Owner access required")
|
||||
for key, value in data.model_dump(exclude_unset=True).items():
|
||||
setattr(workspace, key, value)
|
||||
db.commit()
|
||||
db.refresh(workspace)
|
||||
return workspace
|
||||
|
||||
|
||||
@router.delete("/workspaces/{workspace_id}")
|
||||
def delete_workspace(
|
||||
workspace_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Delete workspace (owner only)."""
|
||||
workspace = check_workspace_access(workspace_id, db, current_user)
|
||||
if workspace.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Owner access required")
|
||||
db.delete(workspace)
|
||||
db.commit()
|
||||
return {"message": "Workspace deleted"}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/workspaces/{workspace_id}/members", response_model=List[WorkspaceMemberRead]
|
||||
)
|
||||
def list_members(
|
||||
workspace_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List workspace members."""
|
||||
check_workspace_access(workspace_id, db, current_user)
|
||||
return (
|
||||
db.query(WorkspaceMember)
|
||||
.filter(WorkspaceMember.workspace_id == workspace_id)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/workspaces/{workspace_id}/members",
|
||||
response_model=WorkspaceMemberRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def add_member(
|
||||
workspace_id: int,
|
||||
data: WorkspaceMemberCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Add member to workspace (owner only)."""
|
||||
workspace = check_workspace_access(workspace_id, db, current_user)
|
||||
if workspace.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Owner access required")
|
||||
membership = WorkspaceMember(
|
||||
workspace_id=workspace_id, user_id=data.user_id, role=data.role
|
||||
)
|
||||
db.add(membership)
|
||||
db.commit()
|
||||
db.refresh(membership)
|
||||
return membership
|
||||
|
||||
|
||||
@router.put(
|
||||
"/workspaces/{workspace_id}/members/{user_id}", response_model=WorkspaceMemberRead
|
||||
)
|
||||
def update_member(
|
||||
workspace_id: int,
|
||||
user_id: int,
|
||||
data: WorkspaceMemberUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Update member role (owner only)."""
|
||||
check_workspace_access(workspace_id, db, current_user)
|
||||
membership = (
|
||||
db.query(WorkspaceMember)
|
||||
.filter(
|
||||
WorkspaceMember.workspace_id == workspace_id,
|
||||
WorkspaceMember.user_id == user_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not membership:
|
||||
raise HTTPException(status_code=404, detail="Member not found")
|
||||
membership.role = data.role
|
||||
db.commit()
|
||||
db.refresh(membership)
|
||||
return membership
|
||||
|
||||
|
||||
@router.delete("/workspaces/{workspace_id}/members/{user_id}")
|
||||
def remove_member(
|
||||
workspace_id: int,
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Remove member from workspace (owner only)."""
|
||||
check_workspace_access(workspace_id, db, current_user)
|
||||
membership = (
|
||||
db.query(WorkspaceMember)
|
||||
.filter(
|
||||
WorkspaceMember.workspace_id == workspace_id,
|
||||
WorkspaceMember.user_id == user_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not membership:
|
||||
raise HTTPException(status_code=404, detail="Member not found")
|
||||
db.delete(membership)
|
||||
db.commit()
|
||||
return {"message": "Member removed"}
|
||||
|
||||
@@ -1,7 +1,23 @@
|
||||
"""Pydantic schemas."""
|
||||
from app.schemas.auth import *
|
||||
from app.schemas.user import *
|
||||
from app.schemas.workspace import *
|
||||
from app.schemas.table import *
|
||||
from app.schemas.record import *
|
||||
from app.schemas.view import *
|
||||
from app.schemas.user import UserCreate, UserRead, UserUpdate
|
||||
from app.schemas.workspace import WorkspaceCreate, WorkspaceRead, WorkspaceUpdate
|
||||
from app.schemas.table import TableCreate, TableRead, TableUpdate
|
||||
from app.schemas.record import RecordCreate, RecordRead, RecordUpdate
|
||||
from app.schemas.auth import Token, TokenData, LoginRequest
|
||||
|
||||
__all__ = [
|
||||
"UserCreate",
|
||||
"UserRead",
|
||||
"UserUpdate",
|
||||
"WorkspaceCreate",
|
||||
"WorkspaceRead",
|
||||
"WorkspaceUpdate",
|
||||
"TableCreate",
|
||||
"TableRead",
|
||||
"TableUpdate",
|
||||
"RecordCreate",
|
||||
"RecordRead",
|
||||
"RecordUpdate",
|
||||
"Token",
|
||||
"TokenData",
|
||||
"LoginRequest",
|
||||
]
|
||||
|
||||
@@ -1,26 +1,66 @@
|
||||
"""Table schemas."""
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
class TableBase(BaseModel):
|
||||
"""Table base schema."""
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ColumnCreate(BaseModel):
|
||||
name: str
|
||||
description: str | None = None
|
||||
type: str # text, number, date, select, etc.
|
||||
required: bool = False
|
||||
default_value: Optional[str] = None
|
||||
position: int = 0
|
||||
options: Optional[dict] = None
|
||||
|
||||
class TableCreate(TableBase):
|
||||
"""Table create schema."""
|
||||
workspace_id: int
|
||||
|
||||
class TableUpdate(BaseModel):
|
||||
"""Table update schema."""
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
|
||||
class TableResponse(TableBase):
|
||||
"""Table response schema."""
|
||||
class ColumnRead(BaseModel):
|
||||
id: int
|
||||
workspace_id: int
|
||||
table_id: int
|
||||
name: str
|
||||
type: str
|
||||
required: bool
|
||||
default_value: Optional[str]
|
||||
position: int
|
||||
options: Optional[dict]
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ColumnUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
type: Optional[str] = None
|
||||
required: Optional[bool] = None
|
||||
default_value: Optional[str] = None
|
||||
position: Optional[int] = None
|
||||
options: Optional[dict] = None
|
||||
|
||||
|
||||
class TableCreate(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class TableRead(BaseModel):
|
||||
id: int
|
||||
workspace_id: int
|
||||
name: str
|
||||
description: Optional[str]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TableUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class TableDetailRead(TableRead):
|
||||
columns: list[ColumnRead] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from passlib.context import CryptContext
|
||||
from jose import JWTError, jwt
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# Password hashing context
|
||||
pwd_context = CryptContext(
|
||||
schemes=["bcrypt"], deprecated="auto", bcrypt__rounds=settings.PASSWORD_COST_FACTOR
|
||||
)
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Verify a password against a hash."""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
"""Hash a password."""
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||
"""Create a JWT access token."""
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.now(timezone.utc) + expires_delta
|
||||
else:
|
||||
expire = datetime.now(timezone.utc) + timedelta(
|
||||
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
|
||||
)
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(
|
||||
to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM
|
||||
)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> Optional[dict]:
|
||||
"""Decode a JWT access token."""
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
|
||||
)
|
||||
return payload
|
||||
except JWTError:
|
||||
return None
|
||||
+127
-35
@@ -1,67 +1,159 @@
|
||||
version: '3.9'
|
||||
# FreeTable Coolify Deployment Configuration
|
||||
# Production stack: Backend (FastAPI+Gunicorn) + Frontend (Nginx) + PostgreSQL
|
||||
|
||||
x-common-env: &common-env
|
||||
TZ: ${TZ:-UTC}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||
|
||||
x-backend-base: &backend-base
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- freetable-network
|
||||
|
||||
x-healthcheck-backend: &healthcheck-backend
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
services:
|
||||
# ==================== PostgreSQL Database ====================
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: freetable
|
||||
POSTGRES_USER: freetable
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
<<: *common-env
|
||||
POSTGRES_DB: ${POSTGRES_DB:-freetable}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-freetable}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme}
|
||||
PGDATA: /var/lib/postgresql/data/pgdata
|
||||
volumes:
|
||||
- freetable-db-data:/var/lib/postgresql/data
|
||||
# Optional: Mount SQL init scripts for schema setup
|
||||
# - ./initdb:/docker-entrypoint-initdb.d:ro
|
||||
networks:
|
||||
- freetable-network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U freetable"]
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-freetable} -d ${POSTGRES_DB:-freetable}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
labels:
|
||||
- "co.anonymous=*"
|
||||
- "co.logdrain=*
|
||||
start_period: 10s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
cpus: '0.5'
|
||||
reservations:
|
||||
memory: 128M
|
||||
cpus: '0.1'
|
||||
|
||||
# ==================== Backend API ====================
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
<<: *backend-base
|
||||
<<: *healthcheck-backend
|
||||
environment:
|
||||
DATABASE_URL: postgresql://freetable:${POSTGRES_PASSWORD}@db:5432/freetable
|
||||
SECRET_KEY: ${SECRET_KEY}
|
||||
CORS_ORIGINS: ${CORS_ORIGINS}
|
||||
APP_NAME: FreeTable
|
||||
APP_VERSION: 1.0.0
|
||||
DEBUG: "false"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
<<: *common-env
|
||||
# Database
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-freetable}:${POSTGRES_PASSWORD:-changeme}@db:5432/${POSTGRES_DB:-freetable}
|
||||
# Auth
|
||||
SECRET_KEY: ${SECRET_KEY:-your-secret-key-change-in-production}
|
||||
ALGORITHM: ${ALGORITHM:-HS256}
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-30}
|
||||
REFRESH_TOKEN_EXPIRE_DAYS: ${REFRESH_TOKEN_EXPIRE_DAYS:-7}
|
||||
# CORS
|
||||
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:3000}
|
||||
# File Uploads
|
||||
UPLOAD_DIR: /app/uploads
|
||||
MAX_UPLOAD_SIZE: ${MAX_UPLOAD_SIZE:-10485760}
|
||||
volumes:
|
||||
- freetable-uploads:/app/uploads
|
||||
expose:
|
||||
- "8000"
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 768M
|
||||
cpus: '1.0'
|
||||
reservations:
|
||||
memory: 256M
|
||||
cpus: '0.25'
|
||||
labels:
|
||||
- "co.anonymous=*"
|
||||
- "co.logdrain=*"
|
||||
# Coolify Discovery
|
||||
coolify.version: "1.0"
|
||||
coolify.project.name: FreeTable
|
||||
coolify.service.name: backend
|
||||
coolify.service.type: api
|
||||
# Traefik labels for reverse proxy
|
||||
traefik.enable: "true"
|
||||
traefik.http.routers.freetable-backend.rule: "PathPrefix(`/api`)"
|
||||
traefik.http.routers.freetable-backend.entrypoints: websecure
|
||||
traefik.http.routers.freetable-backend.tls.certresolver: letsencrypt
|
||||
traefik.http.services.freetable-backend.loadbalancer.server.port: "8000"
|
||||
|
||||
# ==================== Frontend (Nginx) ====================
|
||||
frontend:
|
||||
image: nginx:alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
<<: *common-env
|
||||
BACKEND_HOST: backend
|
||||
BACKEND_PORT: "8000"
|
||||
PORT: "80"
|
||||
volumes:
|
||||
- ./frontend/dist:/usr/share/nginx/html:ro
|
||||
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
ports:
|
||||
- "80:80"
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
networks:
|
||||
- freetable-network
|
||||
depends_on:
|
||||
- backend
|
||||
backend:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
expose:
|
||||
- "80"
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 128M
|
||||
cpus: '0.5'
|
||||
reservations:
|
||||
memory: 32M
|
||||
cpus: '0.1'
|
||||
labels:
|
||||
- "co.anonymous=*"
|
||||
- "co.logdrain=*"
|
||||
# Coolify Discovery
|
||||
coolify.version: "1.0"
|
||||
coolify.project.name: FreeTable
|
||||
coolify.service.name: frontend
|
||||
coolify.service.type: web
|
||||
# Traefik labels for reverse proxy
|
||||
traefik.enable: "true"
|
||||
traefik.http.routers.freetable-web.rule: "Host(`${COOLIFY_DOMAIN}`)"
|
||||
traefik.http.routers.freetable-web.entrypoints: websecure
|
||||
traefik.http.routers.freetable-web.tls.certresolver: letsencrypt
|
||||
traefik.http.services.freetable-web.loadbalancer.server.port: "80"
|
||||
|
||||
# ==================== Networks ====================
|
||||
networks:
|
||||
freetable-network:
|
||||
driver: bridge
|
||||
name: freetable-network
|
||||
|
||||
# ==================== Volumes ====================
|
||||
volumes:
|
||||
freetable-db-data:
|
||||
name: freetable-db-data
|
||||
freetable-uploads:
|
||||
name: freetable-uploads
|
||||
|
||||
+4
-1
@@ -104,5 +104,8 @@ http {
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Health check
|
||||
HEALTH_CHECK
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user