Compare commits

..

2 commits

Author SHA1 Message Date
40adda078e
Add dashboard endpoint 2025-04-28 18:16:51 +00:00
3f130363d7
Encode decimals as number in JSON 2025-04-28 18:16:24 +00:00
8 changed files with 138 additions and 2 deletions

View file

@ -1,6 +1,8 @@
from datetime import UTC, datetime, timedelta
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from fastapi.params import Depends from fastapi.params import Depends
from sqlalchemy import select
from sqlalchemy.orm import Session as DbSession from sqlalchemy.orm import Session as DbSession
from app.database import get_db from app.database import get_db
@ -10,6 +12,9 @@ from app.schemas.user import PasswordUpdate, UserUpdate, User
from app.security.jwt_bearer import JWTBearer from app.security.jwt_bearer import JWTBearer
from app.services import session_service, user_service from app.services import session_service, user_service
from app.util.errors import InvalidStateError, NotFoundError from app.util.errors import InvalidStateError, NotFoundError
from app.schemas.dashboard import DashboardResponse, DashboardStats
from app.models.transaction import Transaction as DbTransaction
from app.schemas.transaction import TransactionStatus
router = APIRouter(prefix="/me", tags=["Me (v1)"]) router = APIRouter(prefix="/me", tags=["Me (v1)"])
@ -108,4 +113,75 @@ async def delete_user_session(
) )
except NotFoundError: except NotFoundError:
raise HTTPException(status_code=404, detail="session_not_found") raise HTTPException(status_code=404, detail="session_not_found")
return list() return list()
@router.get(path="/dashboard", response_model=DashboardResponse)
async def get_dashboard(
db: DbSession = Depends(get_db),
token: AccessToken = Depends(JWTBearer()),
):
"""
Get dashboard information for the currently authenticated user.
"""
# Currently ongoing transaction
stmt_current_transaction = select(DbTransaction).where(DbTransaction.user_id == token.subject).where(DbTransaction.status == TransactionStatus.ONGOING)
current_transaction = db.execute(stmt_current_transaction).scalars().first()
# Common base query
stmt_base = select(DbTransaction).where(DbTransaction.user_id == token.subject).where( DbTransaction.status == TransactionStatus.ENDED).order_by(DbTransaction.ended_at.desc())
# 5 most recent transactions
stmt_transactions_recent = stmt_base.limit(5)
recent_transactions = db.execute(stmt_transactions_recent).scalars().all()
# Calculate beginning of the current and previous month
current_date = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=None)
beginning_current_month = current_date.replace(day=1)
beginning_previous_month = beginning_current_month.replace(
year=(beginning_current_month.year - 1 if beginning_current_month.month == 1 else beginning_current_month.year),
month=(12 if beginning_current_month.month == 1 else beginning_current_month.month - 1)
)
# Transactions for total calculations
stmt_transactions = stmt_base.where(DbTransaction.ended_at >= beginning_previous_month)
transactions = db.execute(stmt_transactions).scalars().all()
# Current month totals
count = 0
energy_total = 0
cost_total = 0
# Previous month totals
count_previous = 0
energy_total_previous = 0
cost_total_previous = 0
# Calculate totals
for trans in transactions:
trans_energy = trans.meter_end - trans.meter_start
trans_cost = trans_energy * trans.price
if trans.ended_at >= beginning_current_month:
# Current month
energy_total += trans_energy
cost_total += trans_cost
count += 1
else:
# Previous month
energy_total_previous += trans_energy
cost_total_previous += trans_cost
count_previous += 1
return DashboardResponse(
stats=DashboardStats(
transaction_count=count,
transaction_count_previous=count_previous,
transaction_energy_total=energy_total,
transaction_energy_total_previous=energy_total_previous,
transaction_cost_total=cost_total,
transaction_cost_total_previous=cost_total_previous
),
current_transaction=current_transaction,
recent_transactions=recent_transactions
)

View file

@ -8,6 +8,8 @@ from app.schemas.connector import Connector
from ocpp.v201.enums import ResetEnumType, ResetStatusEnumType from ocpp.v201.enums import ResetEnumType, ResetStatusEnumType
from app.util.encoders import decimal_encoder
class ChargePointBase(BaseModel): class ChargePointBase(BaseModel):
identity: str identity: str
is_active: bool is_active: bool
@ -32,6 +34,7 @@ class ChargePoint(ChargePointBase):
class Config: class Config:
from_attributes = True from_attributes = True
json_encoders = {Decimal: decimal_encoder}
class ChargePointPassword(BaseModel): class ChargePointPassword(BaseModel):
password: str password: str

View file

@ -4,6 +4,8 @@ from uuid import UUID
from pydantic import BaseModel from pydantic import BaseModel
import enum import enum
from app.util.encoders import decimal_encoder
class AttributeType(enum.Enum): class AttributeType(enum.Enum):
ACTUAL = "Actual" ACTUAL = "Actual"
TARGET = "Target" TARGET = "Target"
@ -52,6 +54,7 @@ class ChargepointVariable(BaseModel):
class Config: class Config:
from_attributes = True from_attributes = True
json_encoders = {Decimal: decimal_encoder}
class ChargepointVariableUpdate(BaseModel): class ChargepointVariableUpdate(BaseModel):
value: str value: str

View file

@ -0,0 +1,25 @@
from decimal import Decimal
from typing import Optional
from pydantic import BaseModel
from app.schemas.transaction import Transaction
from app.util.encoders import decimal_encoder
class DashboardStats(BaseModel):
transaction_count: int
transaction_count_previous: int
transaction_energy_total: Decimal
transaction_energy_total_previous: Decimal
transaction_cost_total: Decimal
transaction_cost_total_previous: Decimal
class Config:
json_encoders = {Decimal: decimal_encoder}
class DashboardResponse(BaseModel):
stats: DashboardStats
current_transaction: Optional[Transaction] = None
recent_transactions: list[Transaction]
class Config:
json_encoders = {Decimal: decimal_encoder}

View file

@ -5,6 +5,8 @@ from typing import Optional
from uuid import UUID from uuid import UUID
from pydantic import BaseModel from pydantic import BaseModel
from app.util.encoders import decimal_encoder
class PhaseType(enum.Enum): class PhaseType(enum.Enum):
L1 = "L1" L1 = "L1"
L2 = "L2" L2 = "L2"
@ -55,3 +57,4 @@ class MeterValue(BaseModel):
class Config: class Config:
from_attributes = True from_attributes = True
json_encoders = {Decimal: decimal_encoder}

View file

@ -8,4 +8,5 @@ class Session(BaseModel):
name: str name: str
last_used: datetime last_used: datetime
model_config = {"from_attributes": True} class Config:
from_attributes = True

View file

@ -5,6 +5,8 @@ from typing import Optional
from uuid import UUID from uuid import UUID
from pydantic import BaseModel from pydantic import BaseModel
from app.util.encoders import decimal_encoder
class TransactionStatus(enum.Enum): class TransactionStatus(enum.Enum):
ONGOING = "ongoing" ONGOING = "ongoing"
ENDED = "ended" ENDED = "ended"
@ -50,6 +52,7 @@ class Transaction(BaseModel):
class Config: class Config:
from_attributes = True from_attributes = True
json_encoders = {Decimal: decimal_encoder}
class RemoteTransactionStartStopResponse(BaseModel): class RemoteTransactionStartStopResponse(BaseModel):
status: RemoteTransactionStartStopStatus status: RemoteTransactionStartStopStatus

View file

@ -0,0 +1,22 @@
from decimal import Decimal
from typing import Union
def decimal_encoder(dec_value: Decimal) -> Union[int, float]:
"""Encodes a Decimal as int of there's no exponent, otherwise float.
This is useful when we use ConstrainedDecimal to represent Numeric(x,0)
where a integer (but not int typed) is used. Encoding this as a float
results in failed round-tripping between encode and parse.
Our Id type is a prime example of this.
>>> decimal_encoder(Decimal("1.0"))
1.0
>>> decimal_encoder(Decimal("1"))
1
"""
exponent = dec_value.as_tuple().exponent
if isinstance(exponent, int) and exponent >= 0:
return int(dec_value)
else:
return float(dec_value)