P1: Role-specific efficiency profiles + BTG tool efficiencies
Backend: - New models: MatchFeedback, EfficiencyProfile, EfficiencyRate, ToolEfficiency, ToolEfficiencyRate - 3 preset profiles seeded: Conservative, Moderate, Aggressive with per-discipline rates - 6 BTG tools seeded: Pencil, OMG, Creative X, Cortex, Semblance, Share of Model - Efficiency API: CRUD for profiles and tools at /api/efficiency/ - team_shape.py: accepts profile_rates + tool_rates (per-discipline, additive, capped at 90%) - team-shape endpoint: accepts profile_id and tool_ids query params - Programme roles always exempt regardless of method Example: Moderate profile + Creative X + Pencil → Account Mgmt 10%, Creative 70%, Production 65% Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b5a21764d8
commit
82046c784c
7 changed files with 337 additions and 17 deletions
|
|
@ -1,7 +1,13 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(ssh optical-dev:*)"
|
||||
"Bash(ssh optical-dev:*)",
|
||||
"Bash(curl -s http://localhost:8001/api/efficiency/profiles)",
|
||||
"Bash(curl -s http://localhost:8002/api/efficiency/profiles)",
|
||||
"Bash(curl -s http://localhost:8002/api/efficiency/tools)",
|
||||
"Bash(python3 -m json.tool)",
|
||||
"Bash(curl -s \"http://localhost:8002/api/projects/5/team-shape?profile_id=2&tool_ids=3,1\")",
|
||||
"Bash(python3 -c \":*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
176
backend/app/api/efficiency.py
Normal file
176
backend/app/api/efficiency.py
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
"""Efficiency profiles and tool efficiency endpoints."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.feedback import EfficiencyProfile, EfficiencyRate, ToolEfficiency, ToolEfficiencyRate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Preset profiles seeded on first access
|
||||
PRESET_PROFILES = {
|
||||
"Conservative": {
|
||||
"Account Management": 5, "Delivery": 10, "Strategy": 5,
|
||||
"Creative": 15, "Editorial": 20, "Production": 25,
|
||||
"Web Design": 20, "Data": 10, "UX": 10,
|
||||
"Tech and Web Dev": 15, "QA": 20,
|
||||
"Social & Community Management": 15, "Third Party Fees": 0,
|
||||
},
|
||||
"Moderate": {
|
||||
"Account Management": 10, "Delivery": 20, "Strategy": 15,
|
||||
"Creative": 35, "Editorial": 40, "Production": 50,
|
||||
"Web Design": 40, "Data": 25, "UX": 25,
|
||||
"Tech and Web Dev": 35, "QA": 45,
|
||||
"Social & Community Management": 30, "Third Party Fees": 0,
|
||||
},
|
||||
"Aggressive": {
|
||||
"Account Management": 15, "Delivery": 30, "Strategy": 25,
|
||||
"Creative": 60, "Editorial": 70, "Production": 80,
|
||||
"Web Design": 65, "Data": 40, "UX": 45,
|
||||
"Tech and Web Dev": 60, "QA": 75,
|
||||
"Social & Community Management": 50, "Third Party Fees": 0,
|
||||
},
|
||||
}
|
||||
|
||||
PRESET_TOOLS = {
|
||||
"Pencil": {
|
||||
"description": "AI-powered ad creative generation and concepting",
|
||||
"rates": {"Creative": 15, "Production": 10, "Delivery": 5},
|
||||
},
|
||||
"OMG": {
|
||||
"description": "Media optimization and campaign management platform",
|
||||
"rates": {"Delivery": 10, "Data": 15, "Strategy": 5},
|
||||
},
|
||||
"Creative X": {
|
||||
"description": "Scalable asset generation and adaptation platform",
|
||||
"rates": {"Creative": 20, "Production": 15, "Editorial": 10},
|
||||
},
|
||||
"Cortex": {
|
||||
"description": "AI model orchestration and experimentation",
|
||||
"rates": {"Data": 20, "Strategy": 10, "Creative": 5, "Production": 5},
|
||||
},
|
||||
"Semblance": {
|
||||
"description": "Audience analysis and model insights",
|
||||
"rates": {"Data": 15, "Strategy": 10},
|
||||
},
|
||||
"Share of Model": {
|
||||
"description": "Media modeling and optimization engine",
|
||||
"rates": {"Data": 15, "Strategy": 10, "Delivery": 5},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def _ensure_presets(db: AsyncSession):
|
||||
"""Seed preset profiles and tools if they don't exist."""
|
||||
existing = await db.execute(select(EfficiencyProfile))
|
||||
if existing.scalars().first():
|
||||
return
|
||||
|
||||
for name, rates in PRESET_PROFILES.items():
|
||||
profile = EfficiencyProfile(name=name, is_default=(name == "Moderate"))
|
||||
db.add(profile)
|
||||
await db.flush()
|
||||
for discipline, pct in rates.items():
|
||||
db.add(EfficiencyRate(profile_id=profile.id, discipline=discipline, efficiency_pct=pct))
|
||||
|
||||
for tool_name, tool_data in PRESET_TOOLS.items():
|
||||
tool = ToolEfficiency(tool_name=tool_name, tool_description=tool_data["description"])
|
||||
db.add(tool)
|
||||
await db.flush()
|
||||
for discipline, pct in tool_data["rates"].items():
|
||||
db.add(ToolEfficiencyRate(tool_id=tool.id, discipline=discipline, additional_efficiency_pct=pct))
|
||||
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.get("/profiles")
|
||||
async def list_profiles(db: AsyncSession = Depends(get_db)):
|
||||
await _ensure_presets(db)
|
||||
result = await db.execute(select(EfficiencyProfile).order_by(EfficiencyProfile.id))
|
||||
profiles = result.scalars().all()
|
||||
|
||||
out = []
|
||||
for p in profiles:
|
||||
rates_result = await db.execute(
|
||||
select(EfficiencyRate).where(EfficiencyRate.profile_id == p.id)
|
||||
)
|
||||
rates = {r.discipline: float(r.efficiency_pct) for r in rates_result.scalars().all()}
|
||||
out.append({
|
||||
"id": p.id,
|
||||
"name": p.name,
|
||||
"is_default": p.is_default,
|
||||
"rates": rates,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/profiles/{profile_id}")
|
||||
async def get_profile(profile_id: int, db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(select(EfficiencyProfile).where(EfficiencyProfile.id == profile_id))
|
||||
profile = result.scalar_one_or_none()
|
||||
if not profile:
|
||||
raise HTTPException(status_code=404, detail="Profile not found")
|
||||
|
||||
rates_result = await db.execute(
|
||||
select(EfficiencyRate).where(EfficiencyRate.profile_id == profile.id)
|
||||
)
|
||||
rates = {r.discipline: float(r.efficiency_pct) for r in rates_result.scalars().all()}
|
||||
return {"id": profile.id, "name": profile.name, "is_default": profile.is_default, "rates": rates}
|
||||
|
||||
|
||||
@router.put("/profiles/{profile_id}")
|
||||
async def update_profile(profile_id: int, data: dict, db: AsyncSession = Depends(get_db)):
|
||||
"""Update rates for a profile. Body: {"rates": {"Creative": 40, "Production": 55, ...}}"""
|
||||
result = await db.execute(select(EfficiencyProfile).where(EfficiencyProfile.id == profile_id))
|
||||
profile = result.scalar_one_or_none()
|
||||
if not profile:
|
||||
raise HTTPException(status_code=404, detail="Profile not found")
|
||||
|
||||
if "rates" in data:
|
||||
# Delete existing rates and re-create
|
||||
existing = await db.execute(select(EfficiencyRate).where(EfficiencyRate.profile_id == profile.id))
|
||||
for r in existing.scalars().all():
|
||||
await db.delete(r)
|
||||
for discipline, pct in data["rates"].items():
|
||||
db.add(EfficiencyRate(profile_id=profile.id, discipline=discipline, efficiency_pct=pct))
|
||||
|
||||
if "name" in data:
|
||||
profile.name = data["name"]
|
||||
|
||||
await db.commit()
|
||||
return await get_profile(profile_id, db)
|
||||
|
||||
|
||||
@router.post("/profiles")
|
||||
async def create_profile(data: dict, db: AsyncSession = Depends(get_db)):
|
||||
"""Create a custom profile. Body: {"name": "My Profile", "rates": {...}}"""
|
||||
profile = EfficiencyProfile(name=data["name"])
|
||||
db.add(profile)
|
||||
await db.flush()
|
||||
for discipline, pct in data.get("rates", {}).items():
|
||||
db.add(EfficiencyRate(profile_id=profile.id, discipline=discipline, efficiency_pct=pct))
|
||||
await db.commit()
|
||||
return await get_profile(profile.id, db)
|
||||
|
||||
|
||||
@router.get("/tools")
|
||||
async def list_tools(db: AsyncSession = Depends(get_db)):
|
||||
await _ensure_presets(db)
|
||||
result = await db.execute(select(ToolEfficiency).order_by(ToolEfficiency.tool_name))
|
||||
tools = result.scalars().all()
|
||||
|
||||
out = []
|
||||
for t in tools:
|
||||
rates_result = await db.execute(
|
||||
select(ToolEfficiencyRate).where(ToolEfficiencyRate.tool_id == t.id)
|
||||
)
|
||||
rates = {r.discipline: float(r.additional_efficiency_pct) for r in rates_result.scalars().all()}
|
||||
out.append({
|
||||
"id": t.id,
|
||||
"tool_name": t.tool_name,
|
||||
"tool_description": t.tool_description,
|
||||
"rates": rates,
|
||||
})
|
||||
return out
|
||||
|
|
@ -138,11 +138,49 @@ async def update_ratecard_line(
|
|||
async def get_team_shape(
|
||||
project_id: int,
|
||||
efficiency_pct: float = 0,
|
||||
profile_id: int | None = None,
|
||||
tool_ids: str = "",
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get team shape (FTE per role) calculated from the ratecard."""
|
||||
"""Get team shape (FTE per role) calculated from the ratecard.
|
||||
|
||||
Can use either:
|
||||
- efficiency_pct: blanket % for all delivery roles (legacy)
|
||||
- profile_id + tool_ids: per-discipline rates from profile + additive tool rates
|
||||
"""
|
||||
project = await _get_project(project_id, db)
|
||||
team = await calculate_team_shape(db, project, efficiency_pct=efficiency_pct)
|
||||
|
||||
profile_rates = None
|
||||
tool_rates_combined = None
|
||||
|
||||
if profile_id:
|
||||
from app.models.feedback import EfficiencyProfile, EfficiencyRate, ToolEfficiency, ToolEfficiencyRate
|
||||
# Load profile rates
|
||||
rates_result = await db.execute(
|
||||
select(EfficiencyRate).where(EfficiencyRate.profile_id == profile_id)
|
||||
)
|
||||
profile_rates = {r.discipline: float(r.efficiency_pct) for r in rates_result.scalars().all()}
|
||||
|
||||
# Load tool rates if specified
|
||||
if tool_ids:
|
||||
tool_id_list = [int(x) for x in tool_ids.split(",") if x.strip().isdigit()]
|
||||
if tool_id_list:
|
||||
tool_rates_combined = {}
|
||||
for tid in tool_id_list:
|
||||
tr_result = await db.execute(
|
||||
select(ToolEfficiencyRate).where(ToolEfficiencyRate.tool_id == tid)
|
||||
)
|
||||
for tr in tr_result.scalars().all():
|
||||
tool_rates_combined[tr.discipline] = (
|
||||
tool_rates_combined.get(tr.discipline, 0) + float(tr.additional_efficiency_pct)
|
||||
)
|
||||
|
||||
team = await calculate_team_shape(
|
||||
db, project,
|
||||
efficiency_pct=efficiency_pct,
|
||||
profile_rates=profile_rates,
|
||||
tool_rates=tool_rates_combined,
|
||||
)
|
||||
|
||||
total_hours = sum(t["total_hours"] for t in team)
|
||||
total_fte = sum(t["fte"] for t in team)
|
||||
|
|
@ -155,6 +193,7 @@ async def get_team_shape(
|
|||
"project_name": project.name,
|
||||
"hours_per_fte": 1800,
|
||||
"efficiency_pct": efficiency_pct,
|
||||
"profile_id": profile_id,
|
||||
"total_hours": round(total_hours, 2),
|
||||
"total_fte": round(total_fte, 4),
|
||||
"adjusted_hours": round(adjusted_hours, 2),
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||
# Enable app-level logging
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s [%(name)s] %(message)s")
|
||||
|
||||
from app.api import gmal, ingest, projects, matching, ratecard
|
||||
from app.api import gmal, ingest, projects, matching, ratecard, efficiency
|
||||
from app.middleware.auth import get_current_user
|
||||
|
||||
app = FastAPI(title="Scope Builder", version="1.0.0")
|
||||
|
|
@ -31,6 +31,7 @@ app.include_router(ingest.router, prefix="/api/gmal", tags=["Ingest"], dependenc
|
|||
app.include_router(projects.router, prefix="/api/projects", tags=["Projects"], dependencies=[_auth])
|
||||
app.include_router(matching.router, prefix="/api/projects", tags=["Matching"], dependencies=[_auth])
|
||||
app.include_router(ratecard.router, prefix="/api/projects", tags=["Ratecard"], dependencies=[_auth])
|
||||
app.include_router(efficiency.router, prefix="/api/efficiency", tags=["Efficiency"], dependencies=[_auth])
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
from app.models.gmal import GmalAsset, Role, GmalHours, GmalServiceLine, RoleLevelMapping
|
||||
from app.models.project import Project, ClientAsset, Match, RatecardLine
|
||||
from app.models.feedback import MatchFeedback, EfficiencyProfile, EfficiencyRate, ToolEfficiency, ToolEfficiencyRate
|
||||
|
||||
__all__ = [
|
||||
"GmalAsset", "Role", "GmalHours", "GmalServiceLine", "RoleLevelMapping",
|
||||
"Project", "ClientAsset", "Match", "RatecardLine",
|
||||
"MatchFeedback", "EfficiencyProfile", "EfficiencyRate",
|
||||
"ToolEfficiency", "ToolEfficiencyRate",
|
||||
]
|
||||
|
|
|
|||
71
backend/app/models/feedback.py
Normal file
71
backend/app/models/feedback.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
"""Models for match feedback, efficiency profiles, and tool efficiencies."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, Text, Integer, Numeric, Boolean, DateTime, ForeignKey, JSON
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class MatchFeedback(Base):
|
||||
"""Stores confirmed/rejected match mappings for learning."""
|
||||
__tablename__ = "match_feedback"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
client_term: Mapped[str] = mapped_column(String(500), index=True)
|
||||
client_description: Mapped[str | None] = mapped_column(Text)
|
||||
gmal_asset_id: Mapped[int] = mapped_column(ForeignKey("gmal_assets.id"), nullable=False)
|
||||
confirmed: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
user_comment: Mapped[str | None] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
gmal_asset: Mapped["GmalAsset"] = relationship()
|
||||
|
||||
|
||||
class EfficiencyProfile(Base):
|
||||
"""Named efficiency profile (Conservative, Moderate, Aggressive, Custom)."""
|
||||
__tablename__ = "efficiency_profiles"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False, unique=True)
|
||||
is_default: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
rates: Mapped[list["EfficiencyRate"]] = relationship(back_populates="profile", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class EfficiencyRate(Base):
|
||||
"""Per-discipline efficiency rate within a profile."""
|
||||
__tablename__ = "efficiency_rates"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
profile_id: Mapped[int] = mapped_column(ForeignKey("efficiency_profiles.id", ondelete="CASCADE"), nullable=False)
|
||||
discipline: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
efficiency_pct: Mapped[float] = mapped_column(Numeric(5, 2), nullable=False)
|
||||
|
||||
profile: Mapped["EfficiencyProfile"] = relationship(back_populates="rates")
|
||||
|
||||
|
||||
class ToolEfficiency(Base):
|
||||
"""A BTG tool that provides additional efficiency."""
|
||||
__tablename__ = "tool_efficiencies"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
tool_name: Mapped[str] = mapped_column(String(100), nullable=False, unique=True)
|
||||
tool_description: Mapped[str | None] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
rates: Mapped[list["ToolEfficiencyRate"]] = relationship(back_populates="tool", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class ToolEfficiencyRate(Base):
|
||||
"""Per-discipline efficiency delta for a specific tool."""
|
||||
__tablename__ = "tool_efficiency_rates"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
tool_id: Mapped[int] = mapped_column(ForeignKey("tool_efficiencies.id", ondelete="CASCADE"), nullable=False)
|
||||
discipline: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
additional_efficiency_pct: Mapped[float] = mapped_column(Numeric(5, 2), nullable=False)
|
||||
|
||||
tool: Mapped["ToolEfficiency"] = relationship(back_populates="rates")
|
||||
|
|
@ -12,23 +12,26 @@ from app.models.project import Project, RatecardLine
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
HOURS_PER_FTE = 1800
|
||||
MAX_EFFICIENCY = 90 # Cap at 90% - never fully eliminate a role
|
||||
|
||||
|
||||
async def calculate_team_shape(
|
||||
db: AsyncSession,
|
||||
project: Project,
|
||||
efficiency_pct: float = 0,
|
||||
profile_rates: dict[str, float] | None = None,
|
||||
tool_rates: dict[str, float] | None = None,
|
||||
) -> list[dict]:
|
||||
"""Calculate FTE headcount per role from the ratecard.
|
||||
|
||||
FTE = total_hours_per_role / 1800
|
||||
Efficiency can be applied in three ways (in priority order):
|
||||
1. profile_rates + tool_rates: per-discipline rates from profile + additive tool rates
|
||||
2. efficiency_pct: legacy blanket percentage for all delivery roles
|
||||
3. None: no efficiency applied
|
||||
|
||||
If efficiency_pct > 0, delivery role hours are reduced by that percentage.
|
||||
Programme roles are NOT reduced (they don't scale with AI efficiency).
|
||||
|
||||
Returns list of dicts sorted by discipline then role order.
|
||||
Programme roles are NEVER reduced regardless of method.
|
||||
Total efficiency per discipline is capped at MAX_EFFICIENCY%.
|
||||
"""
|
||||
# Load ratecard lines
|
||||
lines_result = await db.execute(
|
||||
select(RatecardLine).where(RatecardLine.project_id == project.id)
|
||||
)
|
||||
|
|
@ -48,9 +51,20 @@ async def calculate_team_shape(
|
|||
roles_result = await db.execute(select(Role).where(Role.id.in_(role_ids)))
|
||||
roles = {r.id: r for r in roles_result.scalars().all()}
|
||||
|
||||
# Build team shape
|
||||
efficiency_multiplier = 1 - (efficiency_pct / 100) if efficiency_pct > 0 else 1
|
||||
# Build combined efficiency map per discipline
|
||||
discipline_efficiency = {}
|
||||
if profile_rates:
|
||||
for disc, pct in profile_rates.items():
|
||||
discipline_efficiency[disc] = pct
|
||||
# Add tool rates (additive)
|
||||
if tool_rates:
|
||||
for disc, pct in tool_rates.items():
|
||||
discipline_efficiency[disc] = discipline_efficiency.get(disc, 0) + pct
|
||||
# Cap at MAX_EFFICIENCY
|
||||
for disc in discipline_efficiency:
|
||||
discipline_efficiency[disc] = min(discipline_efficiency[disc], MAX_EFFICIENCY)
|
||||
|
||||
# Build team shape
|
||||
team = []
|
||||
for role_id, total_hours in role_hours.items():
|
||||
role = roles.get(role_id)
|
||||
|
|
@ -59,9 +73,19 @@ async def calculate_team_shape(
|
|||
|
||||
fte = round(total_hours / HOURS_PER_FTE, 4)
|
||||
|
||||
# Apply efficiency - programme roles are NOT reduced
|
||||
if efficiency_pct > 0 and not role.is_programme_role:
|
||||
adjusted_hours = round(total_hours * efficiency_multiplier, 2)
|
||||
# Determine efficiency for this role
|
||||
if role.is_programme_role:
|
||||
eff = 0
|
||||
elif discipline_efficiency:
|
||||
eff = discipline_efficiency.get(role.discipline, 0)
|
||||
elif efficiency_pct > 0:
|
||||
eff = efficiency_pct
|
||||
else:
|
||||
eff = 0
|
||||
|
||||
if eff > 0:
|
||||
multiplier = 1 - (eff / 100)
|
||||
adjusted_hours = round(total_hours * multiplier, 2)
|
||||
adjusted_fte = round(adjusted_hours / HOURS_PER_FTE, 4)
|
||||
else:
|
||||
adjusted_hours = round(total_hours, 2)
|
||||
|
|
@ -75,17 +99,17 @@ async def calculate_team_shape(
|
|||
"sort_order": role.sort_order or 0,
|
||||
"total_hours": round(total_hours, 2),
|
||||
"fte": fte,
|
||||
"efficiency_pct": round(eff, 1),
|
||||
"adjusted_hours": adjusted_hours,
|
||||
"adjusted_fte": adjusted_fte,
|
||||
"hours_saved": round(total_hours - adjusted_hours, 2),
|
||||
"fte_saved": round(fte - adjusted_fte, 4),
|
||||
})
|
||||
|
||||
# Sort by discipline then sort_order
|
||||
team.sort(key=lambda t: (t["discipline"], t["sort_order"]))
|
||||
|
||||
total_hours = sum(t["total_hours"] for t in team)
|
||||
total_fte = sum(t["fte"] for t in team)
|
||||
logger.info(f"Team shape: {len(team)} roles, {total_hours:.0f} hours, {total_fte:.2f} FTE (efficiency: {efficiency_pct}%)")
|
||||
logger.info(f"Team shape: {len(team)} roles, {total_hours:.0f} hours, {total_fte:.2f} FTE")
|
||||
|
||||
return team
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue