From b1a94c535995935ff33af1794e859d5bff3997f6 Mon Sep 17 00:00:00 2001 From: BluemediaDev Date: Mon, 28 Apr 2025 17:52:35 +0000 Subject: [PATCH 1/3] Encode decimals as number in JSON --- backend/app/schemas/chargepoint.py | 3 +++ backend/app/schemas/chargepoint_variable.py | 3 +++ backend/app/schemas/meter_value.py | 3 +++ backend/app/schemas/session.py | 3 ++- backend/app/schemas/transaction.py | 3 +++ backend/app/util/encoders.py | 22 +++++++++++++++++++++ 6 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 backend/app/util/encoders.py diff --git a/backend/app/schemas/chargepoint.py b/backend/app/schemas/chargepoint.py index bf95642..2493315 100644 --- a/backend/app/schemas/chargepoint.py +++ b/backend/app/schemas/chargepoint.py @@ -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 diff --git a/backend/app/schemas/chargepoint_variable.py b/backend/app/schemas/chargepoint_variable.py index 19200eb..f0a3543 100644 --- a/backend/app/schemas/chargepoint_variable.py +++ b/backend/app/schemas/chargepoint_variable.py @@ -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 diff --git a/backend/app/schemas/meter_value.py b/backend/app/schemas/meter_value.py index 7a0343e..99f6945 100644 --- a/backend/app/schemas/meter_value.py +++ b/backend/app/schemas/meter_value.py @@ -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} diff --git a/backend/app/schemas/session.py b/backend/app/schemas/session.py index b6a320b..07b98a1 100644 --- a/backend/app/schemas/session.py +++ b/backend/app/schemas/session.py @@ -8,4 +8,5 @@ class Session(BaseModel): name: str last_used: datetime - model_config = {"from_attributes": True} \ No newline at end of file + class Config: + from_attributes = True \ No newline at end of file diff --git a/backend/app/schemas/transaction.py b/backend/app/schemas/transaction.py index d50ccad..0188f91 100644 --- a/backend/app/schemas/transaction.py +++ b/backend/app/schemas/transaction.py @@ -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 diff --git a/backend/app/util/encoders.py b/backend/app/util/encoders.py new file mode 100644 index 0000000..fbdec8d --- /dev/null +++ b/backend/app/util/encoders.py @@ -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) \ No newline at end of file From 5ad07af3d2481344047216023e350108d0d7ad90 Mon Sep 17 00:00:00 2001 From: BluemediaDev Date: Mon, 28 Apr 2025 17:52:58 +0000 Subject: [PATCH 2/3] Add dashboard endpoint --- backend/app/routers/me_v1.py | 78 +++++++++++++++++++++++++++++++- backend/app/schemas/dashboard.py | 25 ++++++++++ 2 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 backend/app/schemas/dashboard.py diff --git a/backend/app/routers/me_v1.py b/backend/app/routers/me_v1.py index 635f9ed..a5635d5 100644 --- a/backend/app/routers/me_v1.py +++ b/backend/app/routers/me_v1.py @@ -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() \ No newline at end of file + 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 + ) diff --git a/backend/app/schemas/dashboard.py b/backend/app/schemas/dashboard.py new file mode 100644 index 0000000..bd2c0e3 --- /dev/null +++ b/backend/app/schemas/dashboard.py @@ -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} \ No newline at end of file From 255895bcb8d24b42d8dd3da535147fb734204eb9 Mon Sep 17 00:00:00 2001 From: Bluemedia Date: Sun, 27 Apr 2025 13:56:20 +0000 Subject: [PATCH 3/3] WIP: Add chargepoint list --- frontend/src/lib/types/chargepoint.ts | 19 ++++ .../routes/(navbar)/chargepoint/+page.svelte | 98 +++++++++++++++++++ frontend/static/locales/de/chargepoint.json | 13 +++ frontend/static/locales/en/chargepoint.json | 13 +++ 4 files changed, 143 insertions(+) create mode 100644 frontend/src/lib/types/chargepoint.ts create mode 100644 frontend/static/locales/de/chargepoint.json create mode 100644 frontend/static/locales/en/chargepoint.json diff --git a/frontend/src/lib/types/chargepoint.ts b/frontend/src/lib/types/chargepoint.ts new file mode 100644 index 0000000..ade067b --- /dev/null +++ b/frontend/src/lib/types/chargepoint.ts @@ -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' + } + ] +} \ No newline at end of file diff --git a/frontend/src/routes/(navbar)/chargepoint/+page.svelte b/frontend/src/routes/(navbar)/chargepoint/+page.svelte index e69de29..f12908f 100644 --- a/frontend/src/routes/(navbar)/chargepoint/+page.svelte +++ b/frontend/src/routes/(navbar)/chargepoint/+page.svelte @@ -0,0 +1,98 @@ + + +
+

+ {$i18n.t('chargepoint:header')} +

+
+ + + + + + + + + + + + {#if chargepoints.length > 0} + {#each chargepoints as chargepoint} + + + + + + + + {/each} + {/if} + +
{$i18n.t('chargepoint:tableHeader.name')}{$i18n.t('chargepoint:tableHeader.active')}{$i18n.t('chargepoint:tableHeader.lastSeen')}{$i18n.t('chargepoint:tableHeader.price')}
+
+
+

+ {$i18n.t('common:transactionTable.startTime', { + time: transaction.begin, + formatParams: { + time: { + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + second: 'numeric', + }, + }, + })} +

+

+ {$i18n.t('common:transactionTable.endTime', { + time: transaction.end, + formatParams: { + time: { + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + second: 'numeric', + }, + }, + })} +

+
+
+
+ + + {transaction.chargepoint.name} + + {transaction.energyAmmount} kWh{transaction.cost} € + + {$i18n.t('common:transactionTable.detailButton')} + + +
+ {#if chargepoints.length === 0} +

+ {$i18n.t('chargepoint:noChargepoints')} +

+ {/if} +
+
+ + + +
+
diff --git a/frontend/static/locales/de/chargepoint.json b/frontend/static/locales/de/chargepoint.json new file mode 100644 index 0000000..4f76841 --- /dev/null +++ b/frontend/static/locales/de/chargepoint.json @@ -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." +} \ No newline at end of file diff --git a/frontend/static/locales/en/chargepoint.json b/frontend/static/locales/en/chargepoint.json new file mode 100644 index 0000000..0bcbf00 --- /dev/null +++ b/frontend/static/locales/en/chargepoint.json @@ -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." +} \ No newline at end of file