Prepare ERP source for Gitea deployment
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import select
|
||||
from app.core.db.deps import get_common_db
|
||||
from app.modules.core.tenancy.models import Tenant, Branch
|
||||
|
||||
router = APIRouter(prefix="/tenancy", tags=["tenancy"])
|
||||
|
||||
@router.get("/tenants")
|
||||
def list_tenants(db: Session = Depends(get_common_db)):
|
||||
return db.execute(select(Tenant).order_by(Tenant.id)).scalars().all()
|
||||
|
||||
@router.get("/branches")
|
||||
def list_branches(db: Session = Depends(get_common_db)):
|
||||
return db.execute(select(Branch).order_by(Branch.id)).scalars().all()
|
||||
@@ -0,0 +1,126 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, time
|
||||
|
||||
from sqlalchemy import String, Boolean, Integer, Time, Date, DateTime, ForeignKey, UniqueConstraint, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.core.db.common import CommonBase
|
||||
|
||||
|
||||
class Tenant(CommonBase):
|
||||
__tablename__ = "tenants"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
code: Mapped[str] = mapped_column(String(50), unique=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(200))
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
firm_type: Mapped[str] = mapped_column(String(30), default="proprietorship") # partnership|proprietorship|individual
|
||||
|
||||
# Tenant-level defaults
|
||||
default_timezone: Mapped[str] = mapped_column(String(64), default="Asia/Kolkata")
|
||||
default_session_duration_minutes: Mapped[int] = mapped_column(Integer, default=480)
|
||||
default_otp_required_roles_csv: Mapped[str] = mapped_column(
|
||||
String(200),
|
||||
default="Partner,System Admin",
|
||||
)
|
||||
default_storage_mode: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
default="local_only",
|
||||
) # local_only|cloud_only|hybrid
|
||||
|
||||
# Phase 7Q.2 - firm branding defaults
|
||||
display_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
logo_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
favicon_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
primary_color: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
accent_color: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
website_url: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
contact_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
contact_mobile: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
|
||||
|
||||
class Branch(CommonBase):
|
||||
__tablename__ = "branches"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "code", name="uq_branch_tenant_code"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
index=True,
|
||||
)
|
||||
code: Mapped[str] = mapped_column(String(50), index=True)
|
||||
name: Mapped[str] = mapped_column(String(200))
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
timezone: Mapped[str] = mapped_column(String(64), default="Asia/Kolkata")
|
||||
office_start_time: Mapped[time | None] = mapped_column(Time, nullable=True)
|
||||
office_end_time: Mapped[time | None] = mapped_column(Time, nullable=True)
|
||||
|
||||
# Branch control flags
|
||||
allow_login: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
allow_new_assignments: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
is_head_office: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# SMTP (credentials here; policy in BranchSettings)
|
||||
smtp_host: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
smtp_port: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
smtp_username: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
smtp_password: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
smtp_use_tls: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
# Local storage root for branch PC
|
||||
local_storage_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
|
||||
class FinancialYear(CommonBase):
|
||||
__tablename__ = "financial_years"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "year_code", name="uq_financial_year_tenant_code"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
index=True,
|
||||
)
|
||||
year_code: Mapped[str] = mapped_column(String(9), index=True) # e.g. 2025-26
|
||||
assessment_year: Mapped[str] = mapped_column(String(9), index=True) # e.g. 2026-27
|
||||
start_date: Mapped[date] = mapped_column(Date)
|
||||
end_date: Mapped[date] = mapped_column(Date)
|
||||
is_current: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
|
||||
is_locked: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
|
||||
locked_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
locked_by_user_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.utcnow)
|
||||
updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
|
||||
class YearBackupExport(CommonBase):
|
||||
__tablename__ = "year_backup_exports"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
index=True,
|
||||
)
|
||||
financial_year_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("financial_years.id", ondelete="CASCADE"),
|
||||
index=True,
|
||||
)
|
||||
year_code: Mapped[str] = mapped_column(String(9), index=True)
|
||||
assessment_year: Mapped[str | None] = mapped_column(String(9), nullable=True, index=True)
|
||||
export_status: Mapped[str] = mapped_column(String(30), default="completed", index=True)
|
||||
export_file_path: Mapped[str] = mapped_column(String(1000))
|
||||
file_size_bytes: Mapped[int] = mapped_column(Integer, default=0)
|
||||
manifest_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
generated_by_user_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
generated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.utcnow)
|
||||
@@ -0,0 +1,92 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.core.iam.scope import (
|
||||
UserScope,
|
||||
list_visible_branches,
|
||||
list_visible_tenants,
|
||||
)
|
||||
from app.modules.core.tenancy.models import Branch
|
||||
from app.modules.core.iam.services import paginate_list
|
||||
|
||||
|
||||
def build_tenants_payload(
|
||||
db: Session,
|
||||
scope: UserScope,
|
||||
q: str | None = None,
|
||||
page: int = 1,
|
||||
per_page: int = 10,
|
||||
) -> dict:
|
||||
tenants = list_visible_tenants(db, scope)
|
||||
query = (q or "").strip().lower()
|
||||
|
||||
if query:
|
||||
tenants = [
|
||||
t
|
||||
for t in tenants
|
||||
if query in (t.name or "").lower()
|
||||
or query in (t.code or "").lower()
|
||||
]
|
||||
|
||||
paged = paginate_list(tenants, page=page, per_page=per_page)
|
||||
|
||||
return {
|
||||
"tenants": paged.items,
|
||||
"tenants_page": paged,
|
||||
"filters": {
|
||||
"q": (q or "").strip(),
|
||||
"per_page": paged.per_page,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def build_branches_payload(
|
||||
db: Session,
|
||||
scope: UserScope,
|
||||
q: str | None = None,
|
||||
tenant_id: int | None = None,
|
||||
page: int = 1,
|
||||
per_page: int = 10,
|
||||
) -> dict:
|
||||
if scope.is_system_admin:
|
||||
effective_tenant_id = tenant_id
|
||||
|
||||
if effective_tenant_id is None:
|
||||
branches = db.execute(
|
||||
select(Branch).order_by(Branch.name)
|
||||
).scalars().all()
|
||||
else:
|
||||
branches = db.execute(
|
||||
select(Branch)
|
||||
.where(Branch.tenant_id == effective_tenant_id)
|
||||
.order_by(Branch.name)
|
||||
).scalars().all()
|
||||
else:
|
||||
effective_tenant_id = scope.actor.tenant_id
|
||||
branches = list_visible_branches(db, scope, effective_tenant_id)
|
||||
|
||||
query = (q or "").strip().lower()
|
||||
if query:
|
||||
branches = [
|
||||
b
|
||||
for b in branches
|
||||
if query in (b.name or "").lower()
|
||||
or query in (b.code or "").lower()
|
||||
or query in (b.timezone or "").lower()
|
||||
]
|
||||
|
||||
paged = paginate_list(branches, page=page, per_page=per_page)
|
||||
tenants = {t.id: t for t in list_visible_tenants(db, scope)}
|
||||
|
||||
return {
|
||||
"branches": paged.items,
|
||||
"branches_page": paged,
|
||||
"tenants": tenants,
|
||||
"filters": {
|
||||
"q": (q or "").strip(),
|
||||
"per_page": paged.per_page,
|
||||
"tenant_id": effective_tenant_id,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import String, Boolean, Integer, ForeignKey, UniqueConstraint, Text, Float, Time
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.core.db.common import CommonBase
|
||||
|
||||
|
||||
class BranchSettings(CommonBase):
|
||||
__tablename__ = "branch_settings"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("branch_id", name="uq_branch_settings_branch"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
branch_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("branches.id", ondelete="CASCADE"),
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Identity
|
||||
address_line1: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
address_line2: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
city: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
state: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
pin_code: Mapped[str | None] = mapped_column(String(10), nullable=True)
|
||||
gstin: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
pan: Mapped[str | None] = mapped_column(String(10), nullable=True)
|
||||
|
||||
letterhead_logo_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
letterhead_signature_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
letterhead_stamp_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
|
||||
# Geolocation and attendance controls
|
||||
geo_address: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
latitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
longitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
attendance_geo_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
attendance_geo_radius_meters: Mapped[int] = mapped_column(Integer, default=100)
|
||||
attendance_ip_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
attendance_allowed_ip_csv: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Working calendar and attendance timing rules
|
||||
working_days_csv: Mapped[str] = mapped_column(String(50), default="MON,TUE,WED,THU,FRI,SAT")
|
||||
holidays_json: Mapped[str] = mapped_column(Text, default="[]")
|
||||
timezone_locked: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
attendance_grace_minutes: Mapped[int] = mapped_column(Integer, default=10)
|
||||
attendance_half_day_after_time = mapped_column(Time, nullable=True)
|
||||
attendance_rule_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
# Email policy
|
||||
email_from_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
email_from_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
email_reply_to: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
default_cc_csv: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
default_bcc_csv: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
email_signature_html: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Phase 7Q.2 - branding / billing presentation defaults
|
||||
invoice_footer_text: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
bank_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
bank_account_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
bank_account_number: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
bank_ifsc: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
upi_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
|
||||
# Storage policy
|
||||
storage_mode: Mapped[str] = mapped_column(String(20), default="local_only")
|
||||
folder_template: Mapped[str] = mapped_column(
|
||||
String(500),
|
||||
default="{root}/Clients/{client_code}/{fy}/{service}/",
|
||||
)
|
||||
max_file_mb: Mapped[int] = mapped_column(Integer, default=25)
|
||||
allowed_ext_csv: Mapped[str] = mapped_column(
|
||||
String(500),
|
||||
default="pdf,jpg,jpeg,png,xlsx,xls,docx,zip",
|
||||
)
|
||||
retention_years: Mapped[int] = mapped_column(Integer, default=8)
|
||||
|
||||
# Security policy
|
||||
otp_required_roles_csv: Mapped[str] = mapped_column(
|
||||
String(200),
|
||||
default="Partner,System Admin",
|
||||
)
|
||||
session_duration_minutes: Mapped[int] = mapped_column(Integer, default=480)
|
||||
lockout_attempts: Mapped[int] = mapped_column(Integer, default=5)
|
||||
lockout_minutes: Mapped[int] = mapped_column(Integer, default=15)
|
||||
@@ -0,0 +1,56 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.modules.core.tenancy.models import FinancialYear
|
||||
|
||||
|
||||
def active_financial_year_from_request(request: Request) -> str | None:
|
||||
session = request.scope.get("session") or {}
|
||||
value = session.get("active_financial_year") or getattr(request.state, "year_code", None)
|
||||
value = (value or "").strip()
|
||||
if not value or value.upper() == "ALL":
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def get_financial_year(db: Any, *, tenant_id: int, year_code: str | None) -> FinancialYear | None:
|
||||
code = (year_code or "").strip()
|
||||
if not tenant_id or not code:
|
||||
return None
|
||||
return db.execute(
|
||||
select(FinancialYear).where(
|
||||
FinancialYear.tenant_id == int(tenant_id),
|
||||
FinancialYear.year_code == code,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def is_financial_year_locked(db: Any, *, tenant_id: int, year_code: str | None) -> bool:
|
||||
fy = get_financial_year(db, tenant_id=tenant_id, year_code=year_code)
|
||||
return bool(fy and fy.is_locked)
|
||||
|
||||
|
||||
def redirect_if_financial_year_locked(
|
||||
db: Any,
|
||||
*,
|
||||
tenant_id: int,
|
||||
year_code: str | None,
|
||||
redirect_url: str,
|
||||
) -> RedirectResponse | None:
|
||||
if is_financial_year_locked(db, tenant_id=tenant_id, year_code=year_code):
|
||||
separator = "&" if "?" in redirect_url else "?"
|
||||
return RedirectResponse(url=f"{redirect_url}{separator}year_locked=1", status_code=303)
|
||||
return None
|
||||
|
||||
|
||||
def is_row_financial_year_locked(db: Any, row: Any) -> bool:
|
||||
tenant_id = getattr(row, "tenant_id", None)
|
||||
year_code = getattr(row, "financial_year", None)
|
||||
if not tenant_id or not year_code:
|
||||
return False
|
||||
return is_financial_year_locked(db, tenant_id=int(tenant_id), year_code=str(year_code))
|
||||
Reference in New Issue
Block a user