Prepare ERP source for Gitea deployment

This commit is contained in:
A R R R Associates
2026-06-20 15:01:44 +05:30
commit 5c75eb6bd9
450 changed files with 67698 additions and 0 deletions
+15
View File
@@ -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()
+126
View File
@@ -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)
+92
View File
@@ -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)
+56
View File
@@ -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))