Add pyproject.toml for consolidated project configuration and replace Black, isort, and Flake8 with Ruff

- Introduced `pyproject.toml` to centralize backend tool configurations (e.g., Ruff, mypy, coverage, pytest).
- Replaced Black, isort, and Flake8 with Ruff for linting, formatting, and import sorting.
- Updated `requirements.txt` to include Ruff and remove replaced tools.
- Added `Makefile` to streamline development workflows with commands for linting, formatting, type-checking, testing, and cleanup.
This commit is contained in:
2025-11-10 11:55:15 +01:00
parent a5c671c133
commit c589b565f0
86 changed files with 4572 additions and 3956 deletions

View File

@@ -2,17 +2,25 @@
Models package initialization.
Imports all models to ensure they're registered with SQLAlchemy.
"""
# First import Base to avoid circular imports
from app.core.database import Base
from .base import TimestampMixin, UUIDMixin
from .organization import Organization
# Import models
from .user import User
from .user_organization import UserOrganization, OrganizationRole
from .user_organization import OrganizationRole, UserOrganization
from .user_session import UserSession
__all__ = [
'Base', 'TimestampMixin', 'UUIDMixin',
'User', 'UserSession',
'Organization', 'UserOrganization', 'OrganizationRole',
]
"Base",
"Organization",
"OrganizationRole",
"TimestampMixin",
"UUIDMixin",
"User",
"UserOrganization",
"UserSession",
]

View File

@@ -1,20 +1,27 @@
import uuid
from datetime import datetime, timezone
from datetime import UTC, datetime
from sqlalchemy import Column, DateTime
from sqlalchemy.dialects.postgresql import UUID
# noinspection PyUnresolvedReferences
from app.core.database import Base
class TimestampMixin:
"""Mixin to add created_at and updated_at timestamps to models"""
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc), nullable=False)
created_at = Column(
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
)
updated_at = Column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
nullable=False,
)
class UUIDMixin:
"""Mixin to add UUID primary keys to models"""
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)

View File

@@ -1,5 +1,5 @@
# app/models/organization.py
from sqlalchemy import Column, String, Boolean, Text, Index
from sqlalchemy import Boolean, Column, Index, String, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import relationship
@@ -11,7 +11,8 @@ class Organization(Base, UUIDMixin, TimestampMixin):
Organization model for multi-tenant support.
Users can belong to multiple organizations with different roles.
"""
__tablename__ = 'organizations'
__tablename__ = "organizations"
name = Column(String(255), nullable=False, index=True)
slug = Column(String(255), unique=True, nullable=False, index=True)
@@ -20,11 +21,13 @@ class Organization(Base, UUIDMixin, TimestampMixin):
settings = Column(JSONB, default={})
# Relationships
user_organizations = relationship("UserOrganization", back_populates="organization", cascade="all, delete-orphan")
user_organizations = relationship(
"UserOrganization", back_populates="organization", cascade="all, delete-orphan"
)
__table_args__ = (
Index('ix_organizations_name_active', 'name', 'is_active'),
Index('ix_organizations_slug_active', 'slug', 'is_active'),
Index("ix_organizations_name_active", "name", "is_active"),
Index("ix_organizations_slug_active", "slug", "is_active"),
)
def __repr__(self):

View File

@@ -1,4 +1,4 @@
from sqlalchemy import Column, String, Boolean, DateTime
from sqlalchemy import Boolean, Column, DateTime, String
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import relationship
@@ -6,7 +6,7 @@ from .base import Base, TimestampMixin, UUIDMixin
class User(Base, UUIDMixin, TimestampMixin):
__tablename__ = 'users'
__tablename__ = "users"
email = Column(String(255), unique=True, nullable=False, index=True)
password_hash = Column(String(255), nullable=False)
@@ -19,7 +19,9 @@ class User(Base, UUIDMixin, TimestampMixin):
deleted_at = Column(DateTime(timezone=True), nullable=True, index=True)
# Relationships
user_organizations = relationship("UserOrganization", back_populates="user", cascade="all, delete-orphan")
user_organizations = relationship(
"UserOrganization", back_populates="user", cascade="all, delete-orphan"
)
def __repr__(self):
return f"<User {self.email}>"
return f"<User {self.email}>"

View File

@@ -1,7 +1,7 @@
# app/models/user_organization.py
from enum import Enum as PyEnum
from sqlalchemy import Column, ForeignKey, Boolean, String, Index, Enum
from sqlalchemy import Boolean, Column, Enum, ForeignKey, Index, String
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import relationship
@@ -14,6 +14,7 @@ class OrganizationRole(str, PyEnum):
These provide a baseline role system that can be optionally used.
Projects can extend this or implement their own permission system.
"""
OWNER = "owner" # Full control over organization
ADMIN = "admin" # Can manage users and settings
MEMBER = "member" # Regular member with standard access
@@ -25,25 +26,41 @@ class UserOrganization(Base, TimestampMixin):
Junction table for many-to-many relationship between Users and Organizations.
Includes role information for flexible RBAC.
"""
__tablename__ = 'user_organizations'
user_id = Column(PGUUID(as_uuid=True), ForeignKey('users.id', ondelete='CASCADE'), primary_key=True)
organization_id = Column(PGUUID(as_uuid=True), ForeignKey('organizations.id', ondelete='CASCADE'), primary_key=True)
__tablename__ = "user_organizations"
role = Column(Enum(OrganizationRole), default=OrganizationRole.MEMBER, nullable=False, index=True)
user_id = Column(
PGUUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
primary_key=True,
)
organization_id = Column(
PGUUID(as_uuid=True),
ForeignKey("organizations.id", ondelete="CASCADE"),
primary_key=True,
)
role = Column(
Enum(OrganizationRole),
default=OrganizationRole.MEMBER,
nullable=False,
index=True,
)
is_active = Column(Boolean, default=True, nullable=False, index=True)
# Optional: Custom permissions override for specific users
custom_permissions = Column(String(500), nullable=True) # JSON array of permission strings
custom_permissions = Column(
String(500), nullable=True
) # JSON array of permission strings
# Relationships
user = relationship("User", back_populates="user_organizations")
organization = relationship("Organization", back_populates="user_organizations")
__table_args__ = (
Index('ix_user_org_user_active', 'user_id', 'is_active'),
Index('ix_user_org_org_active', 'organization_id', 'is_active'),
Index('ix_user_org_role', 'role'),
Index("ix_user_org_user_active", "user_id", "is_active"),
Index("ix_user_org_org_active", "organization_id", "is_active"),
Index("ix_user_org_role", "role"),
)
def __repr__(self):

View File

@@ -6,7 +6,10 @@ This allows users to:
- Logout from specific devices
- Manage their active sessions
"""
from sqlalchemy import Column, String, Boolean, DateTime, ForeignKey, Index
from datetime import UTC
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Index, String
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
@@ -20,19 +23,27 @@ class UserSession(Base, UUIDMixin, TimestampMixin):
Each time a user logs in from a device, a new session is created.
Sessions are identified by the refresh token JTI (JWT ID).
"""
__tablename__ = 'user_sessions'
__tablename__ = "user_sessions"
# Foreign key to user
user_id = Column(UUID(as_uuid=True), ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True)
user_id = Column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
# Refresh token identifier (JWT ID from the refresh token)
refresh_token_jti = Column(String(255), unique=True, nullable=False, index=True)
# Device information
device_name = Column(String(255), nullable=True) # "iPhone 14", "Chrome on MacBook"
device_id = Column(String(255), nullable=True) # Persistent device identifier (from client)
ip_address = Column(String(45), nullable=True) # IPv4 (15 chars) or IPv6 (45 chars)
user_agent = Column(String(500), nullable=True) # Browser/app user agent
device_id = Column(
String(255), nullable=True
) # Persistent device identifier (from client)
ip_address = Column(String(45), nullable=True) # IPv4 (15 chars) or IPv6 (45 chars)
user_agent = Column(String(500), nullable=True) # Browser/app user agent
# Session timing
last_used_at = Column(DateTime(timezone=True), nullable=False)
@@ -50,8 +61,8 @@ class UserSession(Base, UUIDMixin, TimestampMixin):
# Composite indexes for performance (defined in migration)
__table_args__ = (
Index('ix_user_sessions_user_active', 'user_id', 'is_active'),
Index('ix_user_sessions_jti_active', 'refresh_token_jti', 'is_active'),
Index("ix_user_sessions_user_active", "user_id", "is_active"),
Index("ix_user_sessions_jti_active", "refresh_token_jti", "is_active"),
)
def __repr__(self):
@@ -60,21 +71,24 @@ class UserSession(Base, UUIDMixin, TimestampMixin):
@property
def is_expired(self) -> bool:
"""Check if session has expired."""
from datetime import datetime, timezone
return self.expires_at < datetime.now(timezone.utc)
from datetime import datetime
return self.expires_at < datetime.now(UTC)
def to_dict(self):
"""Convert session to dictionary for serialization."""
return {
'id': str(self.id),
'user_id': str(self.user_id),
'device_name': self.device_name,
'device_id': self.device_id,
'ip_address': self.ip_address,
'last_used_at': self.last_used_at.isoformat() if self.last_used_at else None,
'expires_at': self.expires_at.isoformat() if self.expires_at else None,
'is_active': self.is_active,
'location_city': self.location_city,
'location_country': self.location_country,
'created_at': self.created_at.isoformat() if self.created_at else None,
"id": str(self.id),
"user_id": str(self.user_id),
"device_name": self.device_name,
"device_id": self.device_id,
"ip_address": self.ip_address,
"last_used_at": self.last_used_at.isoformat()
if self.last_used_at
else None,
"expires_at": self.expires_at.isoformat() if self.expires_at else None,
"is_active": self.is_active,
"location_city": self.location_city,
"location_country": self.location_country,
"created_at": self.created_at.isoformat() if self.created_at else None,
}