Compare commits

...

3 commits

Author SHA1 Message Date
255895bcb8
WIP: Add chargepoint list 2025-04-28 18:22:41 +00:00
5ad07af3d2
Add dashboard endpoint 2025-04-28 18:22:40 +00:00
b1a94c5359
Encode decimals as number in JSON 2025-04-28 18:22:36 +00:00
12 changed files with 281 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)"])
@ -109,3 +114,74 @@ 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)

View file

@ -0,0 +1,19 @@
export type ChargePoint = {
identity: string;
is_active: boolean;
price: number;
id: string;
last_seen: string;
vendor_name: string;
model: string;
serial_number: string,
firmware_version: string;
connectors: [
{
id: string;
evse: number;
index: number;
status: 'Available' | 'Occupied' | 'Reserved' | 'Unavailable' | 'Faulted'
}
]
}

View file

@ -0,0 +1,98 @@
<script lang="ts">
import i18n from '$lib/i18n'
import type { ChargePoint } from '$lib/types/chargepoint'
$i18n.loadNamespaces('chargepoint')
let chargepoints: ChargePoint[] = $state([])
let page: number = $state(1)
</script>
<div class="w-full h-full mt-4 flex flex-col">
<p class="w-full text-2xl font-bold">
{$i18n.t('chargepoint:header')}
</p>
<div class="overflow-x-auto mt-8 bg-base-100 rounded-md">
<table class="table">
<thead>
<tr>
<th>{$i18n.t('chargepoint:tableHeader.name')}</th>
<th>{$i18n.t('chargepoint:tableHeader.active')}</th>
<th>{$i18n.t('chargepoint:tableHeader.lastSeen')}</th>
<th>{$i18n.t('chargepoint:tableHeader.price')}</th>
<th></th>
</tr>
</thead>
<tbody>
{#if chargepoints.length > 0}
{#each chargepoints as chargepoint}
<tr>
<td>
<div class="flex items-center gap-3">
<div>
<p>
{$i18n.t('common:transactionTable.startTime', {
time: transaction.begin,
formatParams: {
time: {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
},
},
})}
</p>
<p>
{$i18n.t('common:transactionTable.endTime', {
time: transaction.end,
formatParams: {
time: {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
},
},
})}
</p>
</div>
</div>
</td>
<td>
<a href="/chargepoint/{transaction.chargepoint.id}" class="btn btn-sm btn-primary">
<i class="bi bi-plug-fill text-lg"></i>
{transaction.chargepoint.name}
</a>
</td>
<td class="font-bold">{transaction.energyAmmount} kWh</td>
<td class="font-bold">{transaction.cost}</td>
<th>
<a href="/chargepoint/{chargepoint.id}" class="btn btn-sm btn-primary">
{$i18n.t('common:transactionTable.detailButton')}
<i class="bi bi-arrow-right"></i>
</a>
</th>
</tr>
{/each}
{/if}
</tbody>
</table>
{#if chargepoints.length === 0}
<p class="w-full mt-15 mb-15 text-center text-xl font-bold">
{$i18n.t('chargepoint:noChargepoints')}
</p>
{/if}
</div>
<div class="join mt-4">
<button class="join-item btn bg-base-100">«</button>
<button class="join-item btn bg-base-100">Page {page}</button>
<button class="join-item btn bg-base-100">»</button>
</div>
</div>

View file

@ -0,0 +1,13 @@
{
"header": "Ladepunkte",
"tableHeader": {
"name": "Name",
"active": "Aktiv",
"price": "Preis",
"lastSeen": "Zuletzt gesehen"
},
"tableFormatting": {
"lastSeen": "{{val, relativetime}}"
},
"noChargepoints": "Noch keine Ladepunkte vorhanden."
}

View file

@ -0,0 +1,13 @@
{
"header": "Chargepoints",
"tableHeader": {
"name": "Name",
"active": "Active",
"price": "Price",
"lastSeen": "Last seen"
},
"tableFormatting": {
"lastSeen": "{{val, relativetime}}"
},
"noChargepoints": "There are no chargepoints yet."
}