Compare commits
3 commits
40adda078e
...
255895bcb8
Author | SHA1 | Date | |
---|---|---|---|
255895bcb8 | |||
5ad07af3d2 | |||
b1a94c5359 |
12 changed files with 281 additions and 2 deletions
|
@ -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
|
||||||
|
)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
25
backend/app/schemas/dashboard.py
Normal file
25
backend/app/schemas/dashboard.py
Normal 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}
|
|
@ -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}
|
||||||
|
|
|
@ -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
|
|
@ -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
|
||||||
|
|
22
backend/app/util/encoders.py
Normal file
22
backend/app/util/encoders.py
Normal 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)
|
19
frontend/src/lib/types/chargepoint.ts
Normal file
19
frontend/src/lib/types/chargepoint.ts
Normal 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'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
|
@ -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>
|
13
frontend/static/locales/de/chargepoint.json
Normal file
13
frontend/static/locales/de/chargepoint.json
Normal 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."
|
||||||
|
}
|
13
frontend/static/locales/en/chargepoint.json
Normal file
13
frontend/static/locales/en/chargepoint.json
Normal 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."
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue