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 - \
|
--access-logfile - \
|
||||||
--error-logfile - \
|
--error-logfile - \
|
||||||
--capture-output \
|
--capture-output \
|
||||||
--enable-stdio-inheritance"]
|
--enable-stdio-inheritance"]
|
||||||
|
|||||||
+33
-18
@@ -1,23 +1,38 @@
|
|||||||
"""Application configuration."""
|
|
||||||
import os
|
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
|
||||||
DATABASE_URL: str = os.getenv("DATABASE_URL", "sqlite:///./freetable.db")
|
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 import create_engine
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
from sqlalchemy.orm import sessionmaker, DeclarativeBase
|
||||||
from sqlalchemy.orm import sessionmaker
|
from typing import Generator
|
||||||
from app.config import settings
|
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
# Create engine
|
||||||
engine = create_engine(
|
engine = create_engine(
|
||||||
settings.DATABASE_URL,
|
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)
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
Base = declarative_base()
|
|
||||||
|
|
||||||
def get_db():
|
class Base(DeclarativeBase):
|
||||||
"""Get database session."""
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def get_db() -> Generator:
|
||||||
|
"""Dependency for FastAPI to get database session."""
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
yield db
|
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 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
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
class Attachment(Base):
|
class Attachment(Base):
|
||||||
"""Attachment model."""
|
|
||||||
__tablename__ = "attachments"
|
__tablename__ = "attachments"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
filename = Column(String(255), nullable=False)
|
record_id: Mapped[int] = mapped_column(
|
||||||
filepath = Column(String(500), nullable=False)
|
ForeignKey("records.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
file_size = Column(Integer)
|
)
|
||||||
mime_type = Column(String(100))
|
column_id: Mapped[int] = mapped_column(
|
||||||
record_id = Column(Integer, nullable=True)
|
ForeignKey("columns.id", ondelete="CASCADE"), nullable=True
|
||||||
uploaded_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
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 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
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
class Column(Base):
|
class Column(Base):
|
||||||
"""Column model."""
|
|
||||||
__tablename__ = "columns"
|
__tablename__ = "columns"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
name = Column(String(255), nullable=False)
|
table_id: Mapped[int] = mapped_column(
|
||||||
field_type = Column(String(50), nullable=False) # text, number, date, select, etc.
|
ForeignKey("tables.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
table_id = Column(Integer, ForeignKey("tables.id"), nullable=False)
|
)
|
||||||
position = Column(Integer, default=0)
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
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")
|
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 datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import String, DateTime, ForeignKey, func, Index
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
class Comment(Base):
|
class Comment(Base):
|
||||||
"""Comment model."""
|
|
||||||
__tablename__ = "comments"
|
__tablename__ = "comments"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
content = Column(Text, nullable=False)
|
record_id: Mapped[int] = mapped_column(
|
||||||
author_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
ForeignKey("records.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
table_id = Column(Integer, ForeignKey("tables.id"), nullable=True)
|
)
|
||||||
record_id = Column(Integer, nullable=True)
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
content: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
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 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
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
class Record(Base):
|
class Record(Base):
|
||||||
"""Record model."""
|
|
||||||
__tablename__ = "records"
|
__tablename__ = "records"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
column_id = Column(Integer, ForeignKey("columns.id"), nullable=False)
|
table_id: Mapped[int] = mapped_column(
|
||||||
row_id = Column(Integer, nullable=False)
|
ForeignKey("tables.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
value = Column(Text)
|
)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_by: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
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 datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import String, DateTime, ForeignKey, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
class Table(Base):
|
class Table(Base):
|
||||||
"""Table model."""
|
|
||||||
__tablename__ = "tables"
|
__tablename__ = "tables"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
name = Column(String(255), nullable=False)
|
workspace_id: Mapped[int] = mapped_column(
|
||||||
description = Column(Text)
|
ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
workspace_id = Column(Integer, ForeignKey("workspaces.id"), nullable=False)
|
)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
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")
|
workspace = relationship("Workspace", back_populates="tables")
|
||||||
columns = relationship("Column", back_populates="table", cascade="all, delete-orphan")
|
columns = relationship(
|
||||||
views = relationship("View", back_populates="table")
|
"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 datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import String, DateTime, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
class User(Base):
|
class User(Base):
|
||||||
"""User model."""
|
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
email = Column(String(255), unique=True, index=True, nullable=False)
|
email: Mapped[str] = mapped_column(
|
||||||
username = Column(String(100), unique=True, index=True, nullable=False)
|
String(255), unique=True, nullable=False, index=True
|
||||||
hashed_password = Column(String(255), nullable=False)
|
)
|
||||||
full_name = Column(String(255))
|
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
role: Mapped[str] = mapped_column(String(50), default="member")
|
||||||
is_active = Column(Integer, default=1)
|
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")
|
# Relationships
|
||||||
comments = relationship("Comment", back_populates="author")
|
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 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
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
class View(Base):
|
class View(Base):
|
||||||
"""View model."""
|
|
||||||
__tablename__ = "views"
|
__tablename__ = "views"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
name = Column(String(255), nullable=False)
|
table_id: Mapped[int] = mapped_column(
|
||||||
view_type = Column(String(50), default="grid") # grid, gallery, form
|
ForeignKey("tables.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
table_id = Column(Integer, ForeignKey("tables.id"), nullable=False)
|
)
|
||||||
config = Column(Text) # JSON config for view settings
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
type: Mapped[str] = mapped_column(String(50), default="grid") # grid, gallery, form
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
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")
|
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 datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import String, DateTime, Boolean, ForeignKey, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
class Workspace(Base):
|
class Workspace(Base):
|
||||||
"""Workspace model."""
|
|
||||||
__tablename__ = "workspaces"
|
__tablename__ = "workspaces"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
name = Column(String(255), nullable=False)
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
description = Column(Text)
|
description: Mapped[str] = mapped_column(String, nullable=True)
|
||||||
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
owner_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
is_private: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
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")
|
# Relationships
|
||||||
tables = relationship("Table", back_populates="workspace")
|
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 import APIRouter, Depends, HTTPException, status
|
||||||
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from passlib.context import CryptContext
|
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.auth import Token, LoginRequest
|
from app.schemas.auth import LoginRequest, Token
|
||||||
from app.schemas.user import UserCreate, UserResponse
|
from app.schemas.user import UserCreate, UserRead
|
||||||
from app.config import settings
|
from app.utils.security import (
|
||||||
|
verify_password,
|
||||||
|
get_password_hash,
|
||||||
|
create_access_token,
|
||||||
|
decode_access_token,
|
||||||
|
)
|
||||||
|
|
||||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
router = APIRouter()
|
||||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
|
security = HTTPBearer()
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
|
||||||
|
|
||||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
def get_current_user(
|
||||||
"""Verify password."""
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||||
return pwd_context.verify(plain_password, hashed_password)
|
db: Session = Depends(get_db),
|
||||||
|
) -> User:
|
||||||
def get_password_hash(password: str) -> str:
|
"""Dependency to get the current authenticated user."""
|
||||||
"""Hash password."""
|
token = credentials.credentials
|
||||||
return pwd_context.hash(password)
|
payload = decode_access_token(token)
|
||||||
|
if payload is None:
|
||||||
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):
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
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(
|
user_id = payload.get("sub")
|
||||||
data={"sub": str(user.id)},
|
if user_id is None:
|
||||||
expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
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, Query
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models.record import Record
|
from app.models.user import User
|
||||||
from app.schemas.record import RecordCreate, RecordUpdate, RecordResponse
|
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)
|
@router.get("/tables/{table_id}/records", response_model=List[RecordDetailRead])
|
||||||
def create_record(record: RecordCreate, db: Session = Depends(get_db)):
|
def list_records(
|
||||||
"""Create new record."""
|
table_id: int,
|
||||||
db_record = Record(**record.model_dump())
|
skip: int = Query(0, ge=0),
|
||||||
db.add(db_record)
|
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.commit()
|
||||||
db.refresh(db_record)
|
db.refresh(record)
|
||||||
return db_record
|
return record
|
||||||
|
|
||||||
@router.put("/{record_id}", response_model=RecordResponse)
|
|
||||||
def update_record(record_id: int, record: RecordUpdate, db: Session = Depends(get_db)):
|
@router.get("/tables/{table_id}/records/{record_id}", response_model=RecordDetailRead)
|
||||||
"""Update record."""
|
def get_record(
|
||||||
db_record = db.query(Record).filter(Record.id == record_id).first()
|
table_id: int,
|
||||||
if not db_record:
|
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")
|
raise HTTPException(status_code=404, detail="Record not found")
|
||||||
for key, value in record.model_dump(exclude_unset=True).items():
|
return record
|
||||||
setattr(db_record, key, value)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(db_record)
|
|
||||||
return db_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."""
|
"""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:
|
if not record:
|
||||||
raise HTTPException(status_code=404, detail="Record not found")
|
raise HTTPException(status_code=404, detail="Record not found")
|
||||||
db.delete(record)
|
db.delete(record)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"message": "Record deleted"}
|
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 fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
|
from app.models.user import User
|
||||||
from app.models.table import Table
|
from app.models.table import Table
|
||||||
from app.models.column import Column
|
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 check_table_access(table_id: int, db: Session, user: User) -> Table:
|
||||||
def create_table(table: TableCreate, db: Session = Depends(get_db)):
|
"""Check if user has access to table."""
|
||||||
"""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."""
|
|
||||||
table = db.query(Table).filter(Table.id == table_id).first()
|
table = db.query(Table).filter(Table.id == table_id).first()
|
||||||
if not table:
|
if not table:
|
||||||
raise HTTPException(status_code=404, detail="Table not found")
|
raise HTTPException(status_code=404, detail="Table not found")
|
||||||
|
check_workspace_access(table.workspace_id, db, user)
|
||||||
return table
|
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}")
|
@router.get("/workspaces/{workspace_id}/tables", response_model=List[TableRead])
|
||||||
def delete_table(table_id: int, db: Session = Depends(get_db)):
|
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."""
|
"""Delete table."""
|
||||||
table = db.query(Table).filter(Table.id == table_id).first()
|
table = check_table_access(table_id, db, current_user)
|
||||||
if not table:
|
|
||||||
raise HTTPException(status_code=404, detail="Table not found")
|
|
||||||
db.delete(table)
|
db.delete(table)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"message": "Table deleted"}
|
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")
|
@router.post("/tables/{table_id}/columns", response_model=ColumnRead, status_code=201)
|
||||||
def create_column(table_id: int, column: dict, db: Session = Depends(get_db)):
|
def add_column(
|
||||||
"""Create column in table."""
|
table_id: int,
|
||||||
db_column = Column(table_id=table_id, **column)
|
data: ColumnCreate,
|
||||||
db.add(db_column)
|
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.commit()
|
||||||
db.refresh(db_column)
|
db.refresh(column)
|
||||||
return db_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, status
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models.user import User
|
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 require_admin(current_user: User = Depends(get_current_user)) -> User:
|
||||||
def get_user(user_id: int, db: Session = Depends(get_db)):
|
"""Require admin role."""
|
||||||
"""Get user by ID."""
|
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()
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
return user
|
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 fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
|
from app.models.user import User
|
||||||
from app.models.view import View
|
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)):
|
@router.get("/tables/{table_id}/views", response_model=List[ViewRead])
|
||||||
"""List all views for a table."""
|
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()
|
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)
|
@router.post("/tables/{table_id}/views", response_model=ViewRead, status_code=201)
|
||||||
def get_view(view_id: int, db: Session = Depends(get_db)):
|
def create_view(
|
||||||
"""Get view by ID."""
|
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()
|
view = db.query(View).filter(View.id == view_id).first()
|
||||||
if not view:
|
if not view:
|
||||||
raise HTTPException(status_code=404, detail="View not found")
|
raise HTTPException(status_code=404, detail="View not found")
|
||||||
|
check_table_access(view.table_id, db, current_user)
|
||||||
return view
|
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}")
|
@router.put("/views/{view_id}", response_model=ViewRead)
|
||||||
def delete_view(view_id: int, db: Session = Depends(get_db)):
|
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."""
|
"""Delete view."""
|
||||||
view = db.query(View).filter(View.id == view_id).first()
|
view = db.query(View).filter(View.id == view_id).first()
|
||||||
if not view:
|
if not view:
|
||||||
raise HTTPException(status_code=404, detail="View not found")
|
raise HTTPException(status_code=404, detail="View not found")
|
||||||
|
check_table_access(view.table_id, db, current_user)
|
||||||
db.delete(view)
|
db.delete(view)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"message": "View deleted"}
|
return {"message": "View deleted"}
|
||||||
|
|||||||
@@ -1,54 +1,215 @@
|
|||||||
"""Workspace routes."""
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models.workspace import Workspace
|
from app.models.user import User
|
||||||
from app.schemas.workspace import WorkspaceCreate, WorkspaceUpdate, WorkspaceResponse
|
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 check_workspace_access(workspace_id: int, db: Session, user: User) -> Workspace:
|
||||||
def create_workspace(workspace: WorkspaceCreate, db: Session = Depends(get_db)):
|
"""Check if user has access to workspace."""
|
||||||
"""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."""
|
|
||||||
workspace = db.query(Workspace).filter(Workspace.id == workspace_id).first()
|
workspace = db.query(Workspace).filter(Workspace.id == workspace_id).first()
|
||||||
if not workspace:
|
if not workspace:
|
||||||
raise HTTPException(status_code=404, detail="Workspace not found")
|
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
|
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}")
|
@router.get("/workspaces", response_model=List[WorkspaceRead])
|
||||||
def delete_workspace(workspace_id: int, db: Session = Depends(get_db)):
|
def list_workspaces(
|
||||||
"""Delete workspace."""
|
db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
|
||||||
workspace = db.query(Workspace).filter(Workspace.id == workspace_id).first()
|
):
|
||||||
if not workspace:
|
"""List user's workspaces (owned + member)."""
|
||||||
raise HTTPException(status_code=404, detail="Workspace not found")
|
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.delete(workspace)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"message": "Workspace deleted"}
|
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.user import UserCreate, UserRead, UserUpdate
|
||||||
from app.schemas.auth import *
|
from app.schemas.workspace import WorkspaceCreate, WorkspaceRead, WorkspaceUpdate
|
||||||
from app.schemas.user import *
|
from app.schemas.table import TableCreate, TableRead, TableUpdate
|
||||||
from app.schemas.workspace import *
|
from app.schemas.record import RecordCreate, RecordRead, RecordUpdate
|
||||||
from app.schemas.table import *
|
from app.schemas.auth import Token, TokenData, LoginRequest
|
||||||
from app.schemas.record import *
|
|
||||||
from app.schemas.view import *
|
__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 datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
class TableBase(BaseModel):
|
from pydantic import BaseModel
|
||||||
"""Table base schema."""
|
|
||||||
|
|
||||||
|
class ColumnCreate(BaseModel):
|
||||||
name: str
|
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):
|
class ColumnRead(BaseModel):
|
||||||
"""Table update schema."""
|
|
||||||
name: str | None = None
|
|
||||||
description: str | None = None
|
|
||||||
|
|
||||||
class TableResponse(TableBase):
|
|
||||||
"""Table response schema."""
|
|
||||||
id: int
|
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
|
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:
|
class Config:
|
||||||
from_attributes = True
|
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:
|
services:
|
||||||
|
# ==================== PostgreSQL Database ====================
|
||||||
db:
|
db:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: freetable
|
<<: *common-env
|
||||||
POSTGRES_USER: freetable
|
POSTGRES_DB: ${POSTGRES_DB:-freetable}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
POSTGRES_USER: ${POSTGRES_USER:-freetable}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme}
|
||||||
|
PGDATA: /var/lib/postgresql/data/pgdata
|
||||||
volumes:
|
volumes:
|
||||||
- freetable-db-data:/var/lib/postgresql/data
|
- 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:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U freetable"]
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-freetable} -d ${POSTGRES_DB:-freetable}"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
labels:
|
start_period: 10s
|
||||||
- "co.anonymous=*"
|
deploy:
|
||||||
- "co.logdrain=*
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 512M
|
||||||
|
cpus: '0.5'
|
||||||
|
reservations:
|
||||||
|
memory: 128M
|
||||||
|
cpus: '0.1'
|
||||||
|
|
||||||
|
# ==================== Backend API ====================
|
||||||
backend:
|
backend:
|
||||||
build:
|
<<: *backend-base
|
||||||
context: ./backend
|
<<: *healthcheck-backend
|
||||||
dockerfile: Dockerfile
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql://freetable:${POSTGRES_PASSWORD}@db:5432/freetable
|
<<: *common-env
|
||||||
SECRET_KEY: ${SECRET_KEY}
|
# Database
|
||||||
CORS_ORIGINS: ${CORS_ORIGINS}
|
DATABASE_URL: postgresql://${POSTGRES_USER:-freetable}:${POSTGRES_PASSWORD:-changeme}@db:5432/${POSTGRES_DB:-freetable}
|
||||||
APP_NAME: FreeTable
|
# Auth
|
||||||
APP_VERSION: 1.0.0
|
SECRET_KEY: ${SECRET_KEY:-your-secret-key-change-in-production}
|
||||||
DEBUG: "false"
|
ALGORITHM: ${ALGORITHM:-HS256}
|
||||||
depends_on:
|
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-30}
|
||||||
db:
|
REFRESH_TOKEN_EXPIRE_DAYS: ${REFRESH_TOKEN_EXPIRE_DAYS:-7}
|
||||||
condition: service_healthy
|
# CORS
|
||||||
healthcheck:
|
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:3000}
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
# File Uploads
|
||||||
interval: 30s
|
UPLOAD_DIR: /app/uploads
|
||||||
timeout: 10s
|
MAX_UPLOAD_SIZE: ${MAX_UPLOAD_SIZE:-10485760}
|
||||||
retries: 3
|
volumes:
|
||||||
start_period: 40s
|
- freetable-uploads:/app/uploads
|
||||||
|
expose:
|
||||||
|
- "8000"
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 768M
|
||||||
|
cpus: '1.0'
|
||||||
|
reservations:
|
||||||
|
memory: 256M
|
||||||
|
cpus: '0.25'
|
||||||
labels:
|
labels:
|
||||||
- "co.anonymous=*"
|
# Coolify Discovery
|
||||||
- "co.logdrain=*"
|
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:
|
frontend:
|
||||||
image: nginx:alpine
|
image: nginx:alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
<<: *common-env
|
||||||
|
BACKEND_HOST: backend
|
||||||
|
BACKEND_PORT: "8000"
|
||||||
|
PORT: "80"
|
||||||
volumes:
|
volumes:
|
||||||
- ./frontend/dist:/usr/share/nginx/html:ro
|
- ./frontend/dist:/usr/share/nginx/html:ro
|
||||||
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
ports:
|
networks:
|
||||||
- "80:80"
|
- freetable-network
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost/health"]
|
test: ["CMD", "wget", "-q", "--spider", "http://localhost/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
expose:
|
||||||
|
- "80"
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 128M
|
||||||
|
cpus: '0.5'
|
||||||
|
reservations:
|
||||||
|
memory: 32M
|
||||||
|
cpus: '0.1'
|
||||||
labels:
|
labels:
|
||||||
- "co.anonymous=*"
|
# Coolify Discovery
|
||||||
- "co.logdrain=*"
|
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:
|
volumes:
|
||||||
freetable-db-data:
|
freetable-db-data:
|
||||||
|
name: freetable-db-data
|
||||||
|
freetable-uploads:
|
||||||
|
name: freetable-uploads
|
||||||
|
|||||||
+4
-1
@@ -104,5 +104,8 @@ http {
|
|||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTH_CHECK
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user