Compare commits

...

3 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
e7e7179cd9
WIP: Add chargepoint list 2025-04-27 13:56:20 +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 fastapi import APIRouter, HTTPException
from fastapi.params import Depends
from sqlalchemy import select
from sqlalchemy.orm import Session as DbSession
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.services import session_service, user_service
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)"])
@ -108,4 +113,75 @@ async def delete_user_session(
)
except NotFoundError:
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 app.util.encoders import decimal_encoder
class ChargePointBase(BaseModel):
identity: str
is_active: bool
@ -32,6 +34,7 @@ class ChargePoint(ChargePointBase):
class Config:
from_attributes = True
json_encoders = {Decimal: decimal_encoder}
class ChargePointPassword(BaseModel):
password: str

View file

@ -4,6 +4,8 @@ from uuid import UUID
from pydantic import BaseModel
import enum
from app.util.encoders import decimal_encoder
class AttributeType(enum.Enum):
ACTUAL = "Actual"
TARGET = "Target"
@ -52,6 +54,7 @@ class ChargepointVariable(BaseModel):
class Config:
from_attributes = True
json_encoders = {Decimal: decimal_encoder}
class ChargepointVariableUpdate(BaseModel):
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 pydantic import BaseModel
from app.util.encoders import decimal_encoder
class PhaseType(enum.Enum):
L1 = "L1"
L2 = "L2"
@ -55,3 +57,4 @@ class MeterValue(BaseModel):
class Config:
from_attributes = True
json_encoders = {Decimal: decimal_encoder}

View file

@ -8,4 +8,5 @@ class Session(BaseModel):
name: str
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 pydantic import BaseModel
from app.util.encoders import decimal_encoder
class TransactionStatus(enum.Enum):
ONGOING = "ongoing"
ENDED = "ended"
@ -50,6 +52,7 @@ class Transaction(BaseModel):
class Config:
from_attributes = True
json_encoders = {Decimal: decimal_encoder}
class RemoteTransactionStartStopResponse(BaseModel):
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."
}