Compare commits
No commits in common. "main" and "feature/nats" have entirely different histories.
main
...
feature/na
96 changed files with 1545 additions and 7386 deletions
|
@ -1,24 +0,0 @@
|
||||||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
|
||||||
// README at: https://github.com/devcontainers/templates/tree/main/src/javascript-node
|
|
||||||
{
|
|
||||||
"name": "Python 3.12",
|
|
||||||
|
|
||||||
"dockerComposeFile": "docker-compose.yml",
|
|
||||||
"service": "app",
|
|
||||||
"workspaceFolder": "/simple-ocpp-cs",
|
|
||||||
"shutdownAction": "stopCompose",
|
|
||||||
|
|
||||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
|
||||||
"forwardPorts": [
|
|
||||||
8000
|
|
||||||
],
|
|
||||||
|
|
||||||
// Configure tool-specific properties.
|
|
||||||
"customizations": {
|
|
||||||
"vscode": {
|
|
||||||
"extensions": [
|
|
||||||
"eamodio.gitlens"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,19 +0,0 @@
|
||||||
services:
|
|
||||||
app:
|
|
||||||
image: mcr.microsoft.com/devcontainers/python:3.12-bookworm
|
|
||||||
volumes:
|
|
||||||
- ../..:/simple-ocpp-cs:cached
|
|
||||||
command: sleep infinity
|
|
||||||
|
|
||||||
db:
|
|
||||||
image: postgres:17
|
|
||||||
restart: unless-stopped
|
|
||||||
volumes:
|
|
||||||
- postgres-data:/var/lib/postgresql/data
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: postgres
|
|
||||||
POSTGRES_DB: postgres
|
|
||||||
POSTGRES_PASSWORD: postgres
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
postgres-data:
|
|
|
@ -1,28 +0,0 @@
|
||||||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
|
||||||
// README at: https://github.com/devcontainers/templates/tree/main/src/javascript-node
|
|
||||||
{
|
|
||||||
"name": "Node.js",
|
|
||||||
|
|
||||||
"dockerComposeFile": "docker-compose.yml",
|
|
||||||
"service": "app",
|
|
||||||
"workspaceFolder": "/simple-ocpp-cs",
|
|
||||||
"shutdownAction": "stopCompose",
|
|
||||||
|
|
||||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
|
||||||
"forwardPorts": [
|
|
||||||
5173
|
|
||||||
],
|
|
||||||
|
|
||||||
// Configure tool-specific properties.
|
|
||||||
"customizations": {
|
|
||||||
"vscode": {
|
|
||||||
"extensions": [
|
|
||||||
"svelte.svelte-vscode",
|
|
||||||
"Lokalise.i18n-ally",
|
|
||||||
"dbaeumer.vscode-eslint",
|
|
||||||
"esbenp.prettier-vscode",
|
|
||||||
"eamodio.gitlens"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,6 +0,0 @@
|
||||||
services:
|
|
||||||
app:
|
|
||||||
image: mcr.microsoft.com/devcontainers/javascript-node:1-22-bookworm
|
|
||||||
volumes:
|
|
||||||
- ../..:/simple-ocpp-cs:cached
|
|
||||||
command: sleep infinity
|
|
0
backend/.gitignore → .gitignore
vendored
0
backend/.gitignore → .gitignore
vendored
|
@ -5,13 +5,10 @@ when:
|
||||||
steps:
|
steps:
|
||||||
docker:
|
docker:
|
||||||
image: woodpeckerci/plugin-docker-buildx
|
image: woodpeckerci/plugin-docker-buildx
|
||||||
|
secrets: [docker_username, docker_password]
|
||||||
settings:
|
settings:
|
||||||
pull_image: true
|
pull_image: true
|
||||||
registry: git.bluemedia.dev
|
registry: git.bluemedia.dev
|
||||||
repo: git.bluemedia.dev/bluemedia/simple-ocpp-cs
|
repo: git.bluemedia.dev/bluemedia/simple-ocpp-cs
|
||||||
username:
|
|
||||||
from_secret: docker_username
|
|
||||||
password:
|
|
||||||
from_secret: docker_password
|
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
tag: latest
|
tag: latest
|
16
Dockerfile
16
Dockerfile
|
@ -1,5 +1,5 @@
|
||||||
# `python-base` sets up all our shared environment variables
|
# `python-base` sets up all our shared environment variables
|
||||||
FROM python:3.12-slim AS python-base
|
FROM python:3.11-slim as python-base
|
||||||
|
|
||||||
# python
|
# python
|
||||||
ENV PYTHONUNBUFFERED=1 \
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
|
@ -32,7 +32,7 @@ ENV PATH="$POETRY_HOME/bin:$VENV_PATH/bin:$PATH"
|
||||||
|
|
||||||
|
|
||||||
# `builder-base` stage is used to build deps + create our virtual environment
|
# `builder-base` stage is used to build deps + create our virtual environment
|
||||||
FROM python-base AS builder-base
|
FROM python-base as builder-base
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install --no-install-recommends -y \
|
&& apt-get install --no-install-recommends -y \
|
||||||
# deps for installing poetry
|
# deps for installing poetry
|
||||||
|
@ -45,18 +45,18 @@ RUN curl -sSL https://install.python-poetry.org | python3 -
|
||||||
|
|
||||||
# copy project requirement files here to ensure they will be cached.
|
# copy project requirement files here to ensure they will be cached.
|
||||||
WORKDIR $PYSETUP_PATH
|
WORKDIR $PYSETUP_PATH
|
||||||
COPY backend/poetry.lock backend/pyproject.toml ./
|
COPY poetry.lock pyproject.toml ./
|
||||||
|
|
||||||
# install runtime deps - uses $POETRY_VIRTUALENVS_IN_PROJECT internally
|
# install runtime deps - uses $POETRY_VIRTUALENVS_IN_PROJECT internally
|
||||||
RUN poetry install
|
RUN poetry install --no-dev
|
||||||
|
|
||||||
# `production` image used for runtime
|
# `production` image used for runtime
|
||||||
FROM python-base AS production
|
FROM python-base as production
|
||||||
ENV FASTAPI_ENV=production
|
ENV FASTAPI_ENV=production
|
||||||
COPY --from=builder-base $PYSETUP_PATH $PYSETUP_PATH
|
COPY --from=builder-base $PYSETUP_PATH $PYSETUP_PATH
|
||||||
COPY backend/alembic.ini /usr/src/
|
COPY alembic.ini /usr/src/
|
||||||
COPY start.sh /usr/src/
|
COPY start.sh /usr/src/
|
||||||
COPY backend/alembic /usr/src/alembic
|
COPY ./alembic /usr/src/alembic
|
||||||
COPY backend/app /usr/src/app
|
COPY ./app /usr/src/app
|
||||||
WORKDIR /usr/src
|
WORKDIR /usr/src
|
||||||
CMD ["bash", "start.sh"]
|
CMD ["bash", "start.sh"]
|
|
@ -1,12 +1,9 @@
|
||||||
from logging.config import fileConfig
|
from logging.config import fileConfig
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from alembic import context
|
from alembic import context
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
# Import models for autogenerate support
|
# Import models for autogenerate support
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
from app.models import *
|
from app.models import *
|
|
@ -3,10 +3,7 @@ from sqlalchemy import create_engine
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
if os.getenv("CS_DATABASE_URL", "invalid") == "invalid":
|
SQLALCHEMY_DATABASE_URL = os.getenv("CS_DATABASE_URL", "sqlite:///./simple-ocpp-cs.db")
|
||||||
raise SystemExit('ERROR: Invalid CS_DATABASE_URL environment variable')
|
|
||||||
|
|
||||||
SQLALCHEMY_DATABASE_URL = os.getenv("CS_DATABASE_URL")
|
|
||||||
|
|
||||||
if SQLALCHEMY_DATABASE_URL.startswith("sqlite"):
|
if SQLALCHEMY_DATABASE_URL.startswith("sqlite"):
|
||||||
engine = create_engine(
|
engine = create_engine(
|
40
app/main.py
Normal file
40
app/main.py
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from starlette.middleware.authentication import AuthenticationMiddleware
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
from app.routers import (
|
||||||
|
chargepoint_v1,
|
||||||
|
id_token_v1,
|
||||||
|
meter_value_v1,
|
||||||
|
ocpp_v1,
|
||||||
|
transaction_v1,
|
||||||
|
user_v1
|
||||||
|
)
|
||||||
|
from app.util.websocket_auth_backend import BasicAuthBackend
|
||||||
|
|
||||||
|
def create_ocpp_app():
|
||||||
|
app_ocpp = FastAPI(
|
||||||
|
responses={404: {"description": "Not found"}},
|
||||||
|
)
|
||||||
|
app_ocpp.include_router(ocpp_v1.router)
|
||||||
|
app_ocpp.add_middleware(AuthenticationMiddleware, backend=BasicAuthBackend())
|
||||||
|
|
||||||
|
return app_ocpp
|
||||||
|
|
||||||
|
def create_app():
|
||||||
|
app = FastAPI(
|
||||||
|
responses={404: {"description": "Not found"}},
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(chargepoint_v1.router, prefix="/v1")
|
||||||
|
app.include_router(id_token_v1.router, prefix="/v1")
|
||||||
|
app.include_router(user_v1.router, prefix="/v1")
|
||||||
|
app.include_router(meter_value_v1.router, prefix="/v1")
|
||||||
|
app.include_router(transaction_v1.router, prefix="/v1")
|
||||||
|
app.mount(path="/v1/ocpp", app=create_ocpp_app())
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
app = create_app()
|
|
@ -2,10 +2,8 @@ __all__ = [
|
||||||
"chargepoint_variable",
|
"chargepoint_variable",
|
||||||
"chargepoint",
|
"chargepoint",
|
||||||
"connector",
|
"connector",
|
||||||
"firmware_update",
|
|
||||||
"id_token",
|
"id_token",
|
||||||
"meter_value",
|
"meter_value",
|
||||||
"session",
|
|
||||||
"transaction",
|
"transaction",
|
||||||
"user"
|
"user"
|
||||||
]
|
]
|
15
app/models/user.py
Normal file
15
app/models/user.py
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import uuid
|
||||||
|
from sqlalchemy import Uuid, Boolean, Column, String
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id = Column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||||
|
friendly_name = Column(String, unique=True, index=True)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
|
||||||
|
id_tokens = relationship("IdToken", back_populates="owner", cascade="delete, delete-orphan")
|
||||||
|
transactions = relationship("Transaction", cascade="delete, delete-orphan")
|
|
@ -4,11 +4,10 @@ import os
|
||||||
from ocpp.routing import on, after
|
from ocpp.routing import on, after
|
||||||
from ocpp.v201 import ChargePoint as cp
|
from ocpp.v201 import ChargePoint as cp
|
||||||
from ocpp.v201 import call_result
|
from ocpp.v201 import call_result
|
||||||
from ocpp.v201.enums import Action, RegistrationStatusEnumType, TransactionEventEnumType
|
from ocpp.v201.enums import Action, RegistrationStatusType, TransactionEventType
|
||||||
from ocpp.v201.call import GetBaseReport
|
from ocpp.v201.call import GetBaseReportPayload
|
||||||
|
|
||||||
from app.services import (
|
from app.services import (
|
||||||
firmware_service,
|
|
||||||
variable_service,
|
variable_service,
|
||||||
id_token_service,
|
id_token_service,
|
||||||
chargepoint_service,
|
chargepoint_service,
|
||||||
|
@ -17,42 +16,42 @@ from app.services import (
|
||||||
|
|
||||||
class ChargePoint(cp):
|
class ChargePoint(cp):
|
||||||
|
|
||||||
@on(Action.boot_notification)
|
@on(Action.BootNotification)
|
||||||
async def on_boot_notification(self, charging_station, **kwargs):
|
async def on_boot_notification(self, charging_station, **kwargs):
|
||||||
await chargepoint_service.update_attributes(
|
await chargepoint_service.update_attributes(
|
||||||
chargepoint_identity=self.id,
|
chargepoint_identity=self.id,
|
||||||
charging_station=charging_station
|
charging_station=charging_station
|
||||||
)
|
)
|
||||||
return call_result.BootNotification(
|
return call_result.BootNotificationPayload(
|
||||||
current_time=datetime.now(UTC).isoformat(),
|
current_time=datetime.now(UTC).isoformat(),
|
||||||
interval=int(os.getenv("CS_HEARTBEAT_INTERVAL", "1800")),
|
interval=int(os.getenv("CS_HEARTBEAT_INTERVAL", "1800")),
|
||||||
status=RegistrationStatusEnumType.accepted
|
status=RegistrationStatusType.accepted
|
||||||
)
|
)
|
||||||
|
|
||||||
@after(Action.boot_notification)
|
@after(Action.BootNotification)
|
||||||
async def after_boot_notification(self, **kwargs):
|
async def after_boot_notification(self, **kwargs):
|
||||||
await self.call(payload=GetBaseReport(request_id=0, report_base="FullInventory"))
|
await self.call(payload=GetBaseReportPayload(request_id=0, report_base="FullInventory"))
|
||||||
|
|
||||||
@on(Action.notify_report)
|
@on(Action.NotifyReport)
|
||||||
async def on_notify_report(self, report_data, **kwargs):
|
async def on_notify_report(self, report_data, **kwargs):
|
||||||
for entry in report_data:
|
for entry in report_data:
|
||||||
await variable_service.create_or_update_variable(
|
await variable_service.create_or_update_variable(
|
||||||
chargepoint_identity=self.id,
|
chargepoint_identity=self.id,
|
||||||
report_entry=entry
|
report_entry=entry
|
||||||
)
|
)
|
||||||
return call_result.NotifyReport()
|
return call_result.NotifyReportPayload()
|
||||||
|
|
||||||
@on(Action.heartbeat)
|
@on(Action.Heartbeat)
|
||||||
async def on_heartbeat_request(self):
|
async def on_heartbeat_request(self):
|
||||||
return call_result.Heartbeat(
|
return call_result.HeartbeatPayload(
|
||||||
current_time=datetime.now(UTC).isoformat()
|
current_time=datetime.now(UTC).isoformat()
|
||||||
)
|
)
|
||||||
|
|
||||||
@after(Action.heartbeat)
|
@after(Action.Heartbeat)
|
||||||
async def after_heartbeat_request(self):
|
async def after_heartbeat_request(self):
|
||||||
await chargepoint_service.update_last_seen(chargepoint_identity=self.id)
|
await chargepoint_service.update_last_seen(chargepoint_identity=self.id)
|
||||||
|
|
||||||
@on(Action.status_notification)
|
@on(Action.StatusNotification)
|
||||||
async def on_status_notification(self, evse_id: int, connector_id: int, connector_status: str, **kwargs):
|
async def on_status_notification(self, evse_id: int, connector_id: int, connector_status: str, **kwargs):
|
||||||
await chargepoint_service.create_or_update_connector(
|
await chargepoint_service.create_or_update_connector(
|
||||||
chargepoint_identity=self.id,
|
chargepoint_identity=self.id,
|
||||||
|
@ -60,14 +59,14 @@ class ChargePoint(cp):
|
||||||
connector_id=connector_id,
|
connector_id=connector_id,
|
||||||
connector_status=connector_status
|
connector_status=connector_status
|
||||||
)
|
)
|
||||||
return call_result.StatusNotification()
|
return call_result.StatusNotificationPayload()
|
||||||
|
|
||||||
@on(Action.authorize)
|
@on(Action.Authorize)
|
||||||
async def on_authorize(self, id_token, **kwargs):
|
async def on_authorize(self, id_token, **kwargs):
|
||||||
id_token_info, _ = await id_token_service.get_id_token_info(chargepoint_id=self.id, id_token=id_token)
|
id_token_info, _ = await id_token_service.get_id_token_info(chargepoint_id=self.id, id_token=id_token)
|
||||||
return call_result.Authorize(id_token_info)
|
return call_result.AuthorizePayload(id_token_info)
|
||||||
|
|
||||||
@on(Action.transaction_event)
|
@on(Action.TransactionEvent)
|
||||||
async def on_transaction_event(
|
async def on_transaction_event(
|
||||||
self,
|
self,
|
||||||
event_type,
|
event_type,
|
||||||
|
@ -83,7 +82,7 @@ class ChargePoint(cp):
|
||||||
id_token_info = None
|
id_token_info = None
|
||||||
token_owner_id = None
|
token_owner_id = None
|
||||||
|
|
||||||
if event_type == str(TransactionEventEnumType.started):
|
if event_type == str(TransactionEventType.started):
|
||||||
await transaction_service.create_transaction(
|
await transaction_service.create_transaction(
|
||||||
chargepoint_identity=self.id,
|
chargepoint_identity=self.id,
|
||||||
user_id=token_owner_id,
|
user_id=token_owner_id,
|
||||||
|
@ -91,12 +90,12 @@ class ChargePoint(cp):
|
||||||
transaction_info=transaction_info,
|
transaction_info=transaction_info,
|
||||||
transaction_data=kwargs
|
transaction_data=kwargs
|
||||||
)
|
)
|
||||||
elif event_type == str(TransactionEventEnumType.updated):
|
elif event_type == str(TransactionEventType.updated):
|
||||||
await transaction_service.update_transaction(
|
await transaction_service.update_transaction(
|
||||||
transaction_id=transaction_info["transaction_id"],
|
transaction_id=transaction_info["transaction_id"],
|
||||||
transaction_data=kwargs
|
transaction_data=kwargs
|
||||||
)
|
)
|
||||||
elif event_type == str(TransactionEventEnumType.ended):
|
elif event_type == str(TransactionEventType.ended):
|
||||||
await transaction_service.end_transaction(
|
await transaction_service.end_transaction(
|
||||||
transaction_id=transaction_info["transaction_id"],
|
transaction_id=transaction_info["transaction_id"],
|
||||||
timestamp=datetime.fromisoformat(timestamp),
|
timestamp=datetime.fromisoformat(timestamp),
|
||||||
|
@ -106,19 +105,14 @@ class ChargePoint(cp):
|
||||||
)
|
)
|
||||||
|
|
||||||
if id_token_info == None:
|
if id_token_info == None:
|
||||||
return call_result.TransactionEvent()
|
return call_result.TransactionEventPayload()
|
||||||
else:
|
else:
|
||||||
return call_result.TransactionEvent(id_token_info=id_token_info)
|
return call_result.TransactionEventPayload(id_token_info=id_token_info)
|
||||||
|
|
||||||
@on(Action.firmware_status_notification)
|
@on(Action.MeterValues)
|
||||||
async def on_firmware_status_notification(self, status, request_id, **kwargs):
|
|
||||||
await firmware_service.update_firmware_status(self.id, request_id, status)
|
|
||||||
return call_result.FirmwareStatusNotification()
|
|
||||||
|
|
||||||
@on(Action.meter_values)
|
|
||||||
async def on_meter_values(self, **kwargs):
|
async def on_meter_values(self, **kwargs):
|
||||||
return call_result.MeterValues()
|
return call_result.MeterValuesPayload()
|
||||||
|
|
||||||
@on(Action.security_event_notification)
|
@on(Action.SecurityEventNotification)
|
||||||
async def on_security_event_notification(self, **kwargs):
|
async def on_security_event_notification(self, **kwargs):
|
||||||
return call_result.SecurityEventNotification()
|
return call_result.SecurityEventNotificationPayload()
|
|
@ -3,7 +3,6 @@ from typing import Any, Coroutine, Dict
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from websockets import ConnectionClosed
|
from websockets import ConnectionClosed
|
||||||
from starlette.websockets import WebSocketDisconnect
|
|
||||||
|
|
||||||
from app.ocpp_proto.chargepoint import ChargePoint
|
from app.ocpp_proto.chargepoint import ChargePoint
|
||||||
|
|
||||||
|
@ -13,7 +12,7 @@ async def start(id: UUID, cp: ChargePoint):
|
||||||
try:
|
try:
|
||||||
__active_connections[id] = cp
|
__active_connections[id] = cp
|
||||||
await cp.start()
|
await cp.start()
|
||||||
except (ConnectionClosed, WebSocketDisconnect):
|
except ConnectionClosed:
|
||||||
logging.info("Charging station '%s' (%s) disconnected", cp.id, id)
|
logging.info("Charging station '%s' (%s) disconnected", cp.id, id)
|
||||||
__active_connections.pop(id, None)
|
__active_connections.pop(id, None)
|
||||||
|
|
|
@ -2,15 +2,14 @@ import random
|
||||||
import string
|
import string
|
||||||
from datetime import datetime, timedelta, UTC
|
from datetime import datetime, timedelta, UTC
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException, Security
|
||||||
from fastapi.params import Depends
|
from fastapi.params import Depends
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ocpp.v201.call import Reset, SetVariables
|
from ocpp.v201.call import ResetPayload, SetVariablesPayload
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.ocpp_proto import chargepoint_manager
|
from app.ocpp_proto import chargepoint_manager
|
||||||
from app.schemas.auth_token import AccessToken
|
|
||||||
from app.schemas.chargepoint import (
|
from app.schemas.chargepoint import (
|
||||||
ChargePoint,
|
ChargePoint,
|
||||||
ChargePointCreate,
|
ChargePointCreate,
|
||||||
|
@ -28,13 +27,10 @@ from app.schemas.chargepoint_variable import (
|
||||||
MutabilityType,
|
MutabilityType,
|
||||||
SetVariableStatusType
|
SetVariableStatusType
|
||||||
)
|
)
|
||||||
from app.schemas.firmware_update import FirmwareUpdate, FirmwareUpdateCreate, FirmwareUpdateSubmissionResponse
|
|
||||||
from app.models.chargepoint import ChargePoint as DbChargePoint
|
from app.models.chargepoint import ChargePoint as DbChargePoint
|
||||||
from app.models.user import User as DbUser
|
from app.models.user import User as DbUser
|
||||||
from app.models.chargepoint_variable import ChargepointVariable as DbChargepointVariable
|
from app.models.chargepoint_variable import ChargepointVariable as DbChargepointVariable
|
||||||
from app.models.firmware_update import FirmwareUpdate as DbFirmwareUpdate
|
from app.security import get_api_key
|
||||||
from app.security.jwt_bearer import JWTBearer
|
|
||||||
from app.services import firmware_service
|
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/chargepoints",
|
prefix="/chargepoints",
|
||||||
|
@ -45,16 +41,16 @@ router = APIRouter(
|
||||||
async def get_chargepoints(
|
async def get_chargepoints(
|
||||||
skip: int = 0,
|
skip: int = 0,
|
||||||
limit: int = 20,
|
limit: int = 20,
|
||||||
db: Session = Depends(get_db),
|
api_key: str = Security(get_api_key),
|
||||||
token: AccessToken = Depends(JWTBearer()),
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
return db.query(DbChargePoint).order_by(DbChargePoint.identity).offset(skip).limit(limit).all()
|
return db.query(DbChargePoint).offset(skip).limit(limit).all()
|
||||||
|
|
||||||
@router.get(path="/{chargepoint_id}", response_model=ChargePoint)
|
@router.get(path="/{chargepoint_id}", response_model=ChargePoint)
|
||||||
async def get_chargepoint(
|
async def get_chargepoint(
|
||||||
chargepoint_id: UUID,
|
chargepoint_id: UUID,
|
||||||
db: Session = Depends(get_db),
|
api_key: str = Security(get_api_key),
|
||||||
token: AccessToken = Depends(JWTBearer()),
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
chargepoint = db.get(DbChargePoint, chargepoint_id)
|
chargepoint = db.get(DbChargePoint, chargepoint_id)
|
||||||
if chargepoint is None:
|
if chargepoint is None:
|
||||||
|
@ -64,8 +60,8 @@ async def get_chargepoint(
|
||||||
@router.get(path="/{chargepoint_id}/password", response_model=ChargePointPassword)
|
@router.get(path="/{chargepoint_id}/password", response_model=ChargePointPassword)
|
||||||
async def get_chargepoint_password(
|
async def get_chargepoint_password(
|
||||||
chargepoint_id: UUID,
|
chargepoint_id: UUID,
|
||||||
db: Session = Depends(get_db),
|
api_key: str = Security(get_api_key),
|
||||||
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
chargepoint = db.get(DbChargePoint, chargepoint_id)
|
chargepoint = db.get(DbChargePoint, chargepoint_id)
|
||||||
if chargepoint is None:
|
if chargepoint is None:
|
||||||
|
@ -75,8 +71,8 @@ async def get_chargepoint_password(
|
||||||
@router.delete(path="/{chargepoint_id}/password", response_model=ChargePointPassword)
|
@router.delete(path="/{chargepoint_id}/password", response_model=ChargePointPassword)
|
||||||
async def reset_chargepoint_password(
|
async def reset_chargepoint_password(
|
||||||
chargepoint_id: UUID,
|
chargepoint_id: UUID,
|
||||||
db: Session = Depends(get_db),
|
api_key: str = Security(get_api_key),
|
||||||
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
chargepoint = db.get(DbChargePoint, chargepoint_id)
|
chargepoint = db.get(DbChargePoint, chargepoint_id)
|
||||||
if chargepoint is None:
|
if chargepoint is None:
|
||||||
|
@ -88,8 +84,8 @@ async def reset_chargepoint_password(
|
||||||
@router.post(path="", status_code=201, response_model=ChargePoint)
|
@router.post(path="", status_code=201, response_model=ChargePoint)
|
||||||
async def create_chargepoint(
|
async def create_chargepoint(
|
||||||
chargepoint: ChargePointCreate,
|
chargepoint: ChargePointCreate,
|
||||||
db: Session = Depends(get_db),
|
api_key: str = Security(get_api_key),
|
||||||
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
chargepoint_db = DbChargePoint(
|
chargepoint_db = DbChargePoint(
|
||||||
identity=chargepoint.identity,
|
identity=chargepoint.identity,
|
||||||
|
@ -106,8 +102,8 @@ async def create_chargepoint(
|
||||||
async def update_chargepoint(
|
async def update_chargepoint(
|
||||||
chargepoint_id: UUID,
|
chargepoint_id: UUID,
|
||||||
chargepoint_update: ChargePointUpdate,
|
chargepoint_update: ChargePointUpdate,
|
||||||
db: Session = Depends(get_db),
|
api_key: str = Security(get_api_key),
|
||||||
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
chargepoint = db.get(DbChargePoint, chargepoint_id)
|
chargepoint = db.get(DbChargePoint, chargepoint_id)
|
||||||
if chargepoint is None:
|
if chargepoint is None:
|
||||||
|
@ -120,8 +116,8 @@ async def update_chargepoint(
|
||||||
@router.delete(path="/{chargepoint_id}", response_model=None)
|
@router.delete(path="/{chargepoint_id}", response_model=None)
|
||||||
async def delete_chargepoint(
|
async def delete_chargepoint(
|
||||||
chargepoint_id: UUID,
|
chargepoint_id: UUID,
|
||||||
db: Session = Depends(get_db),
|
api_key: str = Security(get_api_key),
|
||||||
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
chargepoint = db.get(DbChargePoint, chargepoint_id)
|
chargepoint = db.get(DbChargePoint, chargepoint_id)
|
||||||
if chargepoint is None:
|
if chargepoint is None:
|
||||||
|
@ -133,7 +129,7 @@ async def delete_chargepoint(
|
||||||
@router.get(path="/{chargepoint_id}/status", response_model=ChargePointConnectionInfo)
|
@router.get(path="/{chargepoint_id}/status", response_model=ChargePointConnectionInfo)
|
||||||
async def get_chargepoint_status(
|
async def get_chargepoint_status(
|
||||||
chargepoint_id: UUID,
|
chargepoint_id: UUID,
|
||||||
token: AccessToken = Depends(JWTBearer()),
|
api_key: str = Security(get_api_key)
|
||||||
):
|
):
|
||||||
return ChargePointConnectionInfo(
|
return ChargePointConnectionInfo(
|
||||||
connected=chargepoint_manager.is_connected(chargepoint_id)
|
connected=chargepoint_manager.is_connected(chargepoint_id)
|
||||||
|
@ -143,14 +139,14 @@ async def get_chargepoint_status(
|
||||||
async def reset_chargepoint(
|
async def reset_chargepoint(
|
||||||
chargepoint_id: UUID,
|
chargepoint_id: UUID,
|
||||||
reset_request: ChargePointResetRequest,
|
reset_request: ChargePointResetRequest,
|
||||||
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
|
api_key: str = Security(get_api_key)
|
||||||
):
|
):
|
||||||
if chargepoint_manager.is_connected(chargepoint_id) == False:
|
if chargepoint_manager.is_connected(chargepoint_id) == False:
|
||||||
raise HTTPException(status_code=503, detail="Chargepoint not connected.")
|
raise HTTPException(status_code=503, detail="Chargepoint not connected.")
|
||||||
try:
|
try:
|
||||||
response = await chargepoint_manager.call(
|
response = await chargepoint_manager.call(
|
||||||
chargepoint_id,
|
chargepoint_id,
|
||||||
payload=Reset(type=reset_request.type, evse_id=reset_request.evse_id)
|
payload=ResetPayload(type=reset_request.type, evse_id=reset_request.evse_id)
|
||||||
)
|
)
|
||||||
return ChargePointResetResponse(status=response.status)
|
return ChargePointResetResponse(status=response.status)
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
|
@ -160,8 +156,8 @@ async def reset_chargepoint(
|
||||||
async def create_id_token_learn_request(
|
async def create_id_token_learn_request(
|
||||||
chargepoint_id: UUID,
|
chargepoint_id: UUID,
|
||||||
learn_request: IdTokenLearnRequest,
|
learn_request: IdTokenLearnRequest,
|
||||||
db: Session = Depends(get_db),
|
api_key: str = Security(get_api_key),
|
||||||
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
chargepoint = db.get(DbChargePoint, chargepoint_id)
|
chargepoint = db.get(DbChargePoint, chargepoint_id)
|
||||||
if chargepoint is None:
|
if chargepoint is None:
|
||||||
|
@ -192,8 +188,8 @@ async def create_id_token_learn_request(
|
||||||
@router.get(path="/{chargepoint_id}/token-learning", response_model=IdTokenLearnResponse)
|
@router.get(path="/{chargepoint_id}/token-learning", response_model=IdTokenLearnResponse)
|
||||||
async def get_id_token_learn_request(
|
async def get_id_token_learn_request(
|
||||||
chargepoint_id: UUID,
|
chargepoint_id: UUID,
|
||||||
db: Session = Depends(get_db),
|
api_key: str = Security(get_api_key),
|
||||||
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
chargepoint = db.get(DbChargePoint, chargepoint_id)
|
chargepoint = db.get(DbChargePoint, chargepoint_id)
|
||||||
if chargepoint is None:
|
if chargepoint is None:
|
||||||
|
@ -210,8 +206,8 @@ async def get_id_token_learn_request(
|
||||||
@router.delete(path="/{chargepoint_id}/token-learning", response_model=[])
|
@router.delete(path="/{chargepoint_id}/token-learning", response_model=[])
|
||||||
async def get_id_token_learn_request(
|
async def get_id_token_learn_request(
|
||||||
chargepoint_id: UUID,
|
chargepoint_id: UUID,
|
||||||
db: Session = Depends(get_db),
|
api_key: str = Security(get_api_key),
|
||||||
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
chargepoint = db.get(DbChargePoint, chargepoint_id)
|
chargepoint = db.get(DbChargePoint, chargepoint_id)
|
||||||
if chargepoint is None:
|
if chargepoint is None:
|
||||||
|
@ -229,8 +225,8 @@ async def get_id_token_learn_request(
|
||||||
@router.get(path="/{chargepoint_id}/variables", response_model=list[ChargepointVariable])
|
@router.get(path="/{chargepoint_id}/variables", response_model=list[ChargepointVariable])
|
||||||
async def get_chargepoint_variables(
|
async def get_chargepoint_variables(
|
||||||
chargepoint_id: UUID,
|
chargepoint_id: UUID,
|
||||||
db: Session = Depends(get_db),
|
api_key: str = Security(get_api_key),
|
||||||
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
chargepoint = db.get(DbChargePoint, chargepoint_id)
|
chargepoint = db.get(DbChargePoint, chargepoint_id)
|
||||||
if chargepoint is None:
|
if chargepoint is None:
|
||||||
|
@ -243,8 +239,8 @@ async def update_chargepoint_variable(
|
||||||
chargepoint_id: UUID,
|
chargepoint_id: UUID,
|
||||||
variable_id: UUID,
|
variable_id: UUID,
|
||||||
variable_update: ChargepointVariableUpdate,
|
variable_update: ChargepointVariableUpdate,
|
||||||
db: Session = Depends(get_db),
|
api_key: str = Security(get_api_key),
|
||||||
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
chargepoint = db.get(DbChargePoint, chargepoint_id)
|
chargepoint = db.get(DbChargePoint, chargepoint_id)
|
||||||
if chargepoint is None:
|
if chargepoint is None:
|
||||||
|
@ -273,7 +269,7 @@ async def update_chargepoint_variable(
|
||||||
evse['connectorId'] = variable.connector_id
|
evse['connectorId'] = variable.connector_id
|
||||||
result = await chargepoint_manager.call(
|
result = await chargepoint_manager.call(
|
||||||
chargepoint_id,
|
chargepoint_id,
|
||||||
payload=SetVariables(set_variable_data=[
|
payload=SetVariablesPayload(set_variable_data=[
|
||||||
{
|
{
|
||||||
'attributeType': variable.type.value,
|
'attributeType': variable.type.value,
|
||||||
'attributeValue': variable_update.value,
|
'attributeValue': variable_update.value,
|
||||||
|
@ -296,67 +292,3 @@ async def update_chargepoint_variable(
|
||||||
return ChargepointVariableResponse(status=status)
|
return ChargepointVariableResponse(status=status)
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
raise HTTPException(status_code=503, detail="Chargepoint didn't respond in time.")
|
raise HTTPException(status_code=503, detail="Chargepoint didn't respond in time.")
|
||||||
|
|
||||||
@router.get(path="/{chargepoint_id}/firmware-updates", response_model=list[FirmwareUpdate])
|
|
||||||
async def get_firmware_updates(
|
|
||||||
chargepoint_id: UUID,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
|
|
||||||
):
|
|
||||||
chargepoint = db.get(DbChargePoint, chargepoint_id)
|
|
||||||
if chargepoint is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Chargepoint not found")
|
|
||||||
|
|
||||||
firmware_updates = db.query(DbFirmwareUpdate).filter(
|
|
||||||
DbFirmwareUpdate.chargepoint_id == chargepoint_id
|
|
||||||
).all()
|
|
||||||
|
|
||||||
return firmware_updates
|
|
||||||
|
|
||||||
@router.get(path="/{chargepoint_id}/firmware-updates/{firmware_update_id}", response_model=FirmwareUpdate)
|
|
||||||
async def get_firmware_update(
|
|
||||||
chargepoint_id: UUID,
|
|
||||||
firmware_update_id: UUID,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
|
|
||||||
):
|
|
||||||
chargepoint = db.get(DbChargePoint, chargepoint_id)
|
|
||||||
if chargepoint is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Chargepoint not found")
|
|
||||||
|
|
||||||
firmware_update = db.query(DbFirmwareUpdate).filter(
|
|
||||||
DbFirmwareUpdate.chargepoint_id == chargepoint_id,
|
|
||||||
DbFirmwareUpdate.id == firmware_update_id
|
|
||||||
).first()
|
|
||||||
if firmware_update is None:
|
|
||||||
raise HTTPException(status_code=404, detail="FirmwareUpdate not found")
|
|
||||||
|
|
||||||
return firmware_update
|
|
||||||
|
|
||||||
@router.post(path="/{chargepoint_id}/firmware-updates", response_model=FirmwareUpdate)
|
|
||||||
async def create_firmware_update(
|
|
||||||
chargepoint_id: UUID,
|
|
||||||
firmware_update: FirmwareUpdateCreate,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
|
|
||||||
):
|
|
||||||
chargepoint = db.get(DbChargePoint, chargepoint_id)
|
|
||||||
if chargepoint is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Chargepoint not found")
|
|
||||||
|
|
||||||
firmware_update = await firmware_service.create_firmware_update(chargepoint_id, firmware_update)
|
|
||||||
return firmware_update
|
|
||||||
|
|
||||||
@router.post(path="/{chargepoint_id}/firmware-updates/{firmware_update_id}/submit", response_model=ChargePointResetResponse)
|
|
||||||
async def submit_firmware_update(
|
|
||||||
chargepoint_id: UUID,
|
|
||||||
firmware_update_id: UUID,
|
|
||||||
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
|
|
||||||
):
|
|
||||||
if chargepoint_manager.is_connected(chargepoint_id) == False:
|
|
||||||
raise HTTPException(status_code=503, detail="Chargepoint not connected.")
|
|
||||||
try:
|
|
||||||
_, status = await firmware_service.submit_firmware_update(firmware_update_id)
|
|
||||||
return FirmwareUpdateSubmissionResponse(status=status)
|
|
||||||
except TimeoutError:
|
|
||||||
raise HTTPException(status_code=503, detail="Chargepoint didn't respond in time.")
|
|
|
@ -1,16 +1,14 @@
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException, Security
|
||||||
|
from fastapi.exceptions import RequestValidationError
|
||||||
from fastapi.params import Depends
|
from fastapi.params import Depends
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.schemas.auth_token import AccessToken
|
|
||||||
from app.schemas.id_token import IdToken, IdTokenCreate, IdTokenUpdate
|
from app.schemas.id_token import IdToken, IdTokenCreate, IdTokenUpdate
|
||||||
from app.models.id_token import IdToken as DbIdToken
|
from app.models.id_token import IdToken as DbIdToken
|
||||||
from app.models.user import User as DbUser
|
from app.models.user import User as DbUser
|
||||||
from app.schemas.user import Role
|
from app.security import get_api_key
|
||||||
from app.security.jwt_bearer import JWTBearer
|
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/id-tokens",
|
prefix="/id-tokens",
|
||||||
|
@ -18,43 +16,32 @@ router = APIRouter(
|
||||||
)
|
)
|
||||||
|
|
||||||
@router.get(path="", response_model=list[IdToken])
|
@router.get(path="", response_model=list[IdToken])
|
||||||
async def get_id_tokens(
|
async def get_it_tokens(
|
||||||
skip: int = 0,
|
skip: int = 0,
|
||||||
limit: int = 20,
|
limit: int = 20,
|
||||||
db: Session = Depends(get_db),
|
api_key: str = Security(get_api_key),
|
||||||
token: AccessToken = Depends(JWTBearer()),
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
stmt = select(DbIdToken)
|
return db.query(DbIdToken).offset(skip).limit(limit).all()
|
||||||
if token.role != Role.ADMINISTRATOR:
|
|
||||||
stmt = stmt.where(DbIdToken.owner_id == token.subject)
|
|
||||||
stmt = stmt.order_by(DbIdToken.id).offset(skip).limit(limit)
|
|
||||||
result = db.execute(stmt)
|
|
||||||
return result.scalars().all()
|
|
||||||
|
|
||||||
@router.get(path="/{id_token_id}", response_model=IdToken)
|
@router.get(path="/{id_token_id}", response_model=IdToken)
|
||||||
async def get_id_token(
|
async def get_id_token(
|
||||||
id_token_id: UUID,
|
id_token_id: UUID,
|
||||||
db: Session = Depends(get_db),
|
api_key: str = Security(get_api_key),
|
||||||
token: AccessToken = Depends(JWTBearer()),
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
stmt = select(DbIdToken).where(DbIdToken.id == id_token_id)
|
id_token = db.get(DbIdToken, id_token_id)
|
||||||
result = db.execute(stmt)
|
|
||||||
id_token = result.scalars().first()
|
|
||||||
if id_token == None:
|
if id_token == None:
|
||||||
raise HTTPException(status_code=404, detail="IdToken not found")
|
raise HTTPException(status_code=404, detail="IdToken not found")
|
||||||
if token.role != Role.ADMINISTRATOR and id_token.owner_id != token.subject:
|
|
||||||
raise HTTPException(status_code=404, detail="IdToken not found")
|
|
||||||
return id_token
|
return id_token
|
||||||
|
|
||||||
@router.post(path="", status_code=201, response_model=IdToken)
|
@router.post(path="", status_code=201, response_model=IdToken)
|
||||||
async def create_id_token(
|
async def create_id_token(
|
||||||
create_id_token: IdTokenCreate,
|
create_id_token: IdTokenCreate,
|
||||||
db: Session = Depends(get_db),
|
api_key: str = Security(get_api_key),
|
||||||
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
stmt = select(DbUser).where(DbUser.id == create_id_token.owner_id)
|
owner = db.get(DbUser, create_id_token.owner_id)
|
||||||
result = db.execute(stmt)
|
|
||||||
owner = result.scalars().first()
|
|
||||||
if owner == None:
|
if owner == None:
|
||||||
raise HTTPException(status_code=422, detail=[{
|
raise HTTPException(status_code=422, detail=[{
|
||||||
"loc": ["body", "owner_id"],
|
"loc": ["body", "owner_id"],
|
||||||
|
@ -76,19 +63,15 @@ async def create_id_token(
|
||||||
async def update_id_token(
|
async def update_id_token(
|
||||||
id_token_id: UUID,
|
id_token_id: UUID,
|
||||||
id_token_update: IdTokenUpdate,
|
id_token_update: IdTokenUpdate,
|
||||||
db: Session = Depends(get_db),
|
api_key: str = Security(get_api_key),
|
||||||
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
stmt = select(DbIdToken).where(DbIdToken.id == id_token_id)
|
id_token = db.get(DbIdToken, id_token_id)
|
||||||
result = db.execute(stmt)
|
|
||||||
id_token = result.scalars().first()
|
|
||||||
if id_token is None:
|
if id_token is None:
|
||||||
raise HTTPException(status_code=404, detail="IdToken not found")
|
raise HTTPException(status_code=404, detail="IdToken not found")
|
||||||
for key, value in id_token_update.model_dump(exclude_unset=True).items():
|
for key, value in id_token_update.model_dump(exclude_unset=True).items():
|
||||||
if key == "owner_id":
|
if key == "owner_id":
|
||||||
stmt = select(DbUser).where(DbUser.id == id_token_update.owner_id)
|
owner = db.get(DbUser, value)
|
||||||
result = db.execute(stmt)
|
|
||||||
owner = result.scalars().first()
|
|
||||||
if owner == None:
|
if owner == None:
|
||||||
raise HTTPException(status_code=422, detail=[{
|
raise HTTPException(status_code=422, detail=[{
|
||||||
"loc": ["body", "owner_id"],
|
"loc": ["body", "owner_id"],
|
||||||
|
@ -102,12 +85,10 @@ async def update_id_token(
|
||||||
@router.delete(path="/{id_token_id}", response_model=None)
|
@router.delete(path="/{id_token_id}", response_model=None)
|
||||||
async def delete_id_token(
|
async def delete_id_token(
|
||||||
id_token_id: UUID,
|
id_token_id: UUID,
|
||||||
db: Session = Depends(get_db),
|
api_key: str = Security(get_api_key),
|
||||||
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
stmt = select(DbIdToken).where(DbIdToken.id == id_token_id)
|
id_token = db.get(DbIdToken, id_token_id)
|
||||||
result = db.execute(stmt)
|
|
||||||
id_token = result.scalars().first()
|
|
||||||
if id_token == None:
|
if id_token == None:
|
||||||
raise HTTPException(status_code=404, detail="IdToken not found")
|
raise HTTPException(status_code=404, detail="IdToken not found")
|
||||||
db.delete(id_token)
|
db.delete(id_token)
|
|
@ -1,12 +1,11 @@
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from sqlalchemy import select
|
from fastapi.params import Security
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.schemas.auth_token import AccessToken
|
from app.security import get_api_key
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.schemas.meter_value import MeterValue
|
from app.schemas.meter_value import MeterValue
|
||||||
from app.models.meter_value import MeterValue as DbMeterValue
|
from app.models.meter_value import MeterValue as DbMeterValue
|
||||||
from app.security.jwt_bearer import JWTBearer
|
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/meter-values",
|
prefix="/meter-values",
|
||||||
|
@ -17,9 +16,7 @@ router = APIRouter(
|
||||||
async def get_meter_values(
|
async def get_meter_values(
|
||||||
skip: int = 0,
|
skip: int = 0,
|
||||||
limit: int = 20,
|
limit: int = 20,
|
||||||
db: Session = Depends(get_db),
|
api_key: str = Security(get_api_key),
|
||||||
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
stmt = select(DbMeterValue).order_by(DbMeterValue.timestamp).offset(skip).limit(limit)
|
return db.query(DbMeterValue).offset(skip).limit(limit).all()
|
||||||
result = db.execute(stmt)
|
|
||||||
return result.scalars().all()
|
|
66
app/routers/transaction_v1.py
Normal file
66
app/routers/transaction_v1.py
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from fastapi.params import Security
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ocpp.v201.call import RequestStopTransactionPayload
|
||||||
|
|
||||||
|
from app.ocpp_proto import chargepoint_manager
|
||||||
|
from app.security import get_api_key
|
||||||
|
from app.database import get_db
|
||||||
|
from app.schemas.transaction import Transaction, RemoteTransactionStartStopResponse, TransactionStatus, RemoteTransactionStartStopStatus
|
||||||
|
from app.models.transaction import Transaction as DbTransaction
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/transactions",
|
||||||
|
tags=["Transaction (v1)"]
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get(path="", response_model=list[Transaction])
|
||||||
|
async def get_transactions(
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 20,
|
||||||
|
api_key: str = Security(get_api_key),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
return db.query(DbTransaction).offset(skip).limit(limit).all()
|
||||||
|
|
||||||
|
@router.get(path="/{transaction_id}", response_model=Transaction)
|
||||||
|
async def get_transaction(
|
||||||
|
transaction_id: str,
|
||||||
|
api_key: str = Security(get_api_key),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
transaction = db.get(DbTransaction, transaction_id)
|
||||||
|
if transaction == None:
|
||||||
|
raise HTTPException(404, "Transaction not found")
|
||||||
|
return transaction
|
||||||
|
|
||||||
|
@router.post(path="/{transaction_id}/remote-stop", response_model=RemoteTransactionStartStopResponse)
|
||||||
|
async def remote_stop_transaction(
|
||||||
|
transaction_id: str,
|
||||||
|
api_key: str = Security(get_api_key),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
transaction = db.get(DbTransaction, transaction_id)
|
||||||
|
if transaction == None:
|
||||||
|
raise HTTPException(404, "Transaction not found")
|
||||||
|
if transaction.status != TransactionStatus.ONGOING:
|
||||||
|
raise HTTPException(status_code=422, detail=[{
|
||||||
|
"loc": ["path", "transaction_id"],
|
||||||
|
"msg": "Transaction is not ongoing",
|
||||||
|
"type": "invalid_transaction_state"
|
||||||
|
}])
|
||||||
|
if chargepoint_manager.is_connected(transaction.chargepoint_id) == False:
|
||||||
|
raise HTTPException(status_code=503, detail="Chargepoint not connected.")
|
||||||
|
try:
|
||||||
|
result = await chargepoint_manager.call(
|
||||||
|
transaction.chargepoint_id,
|
||||||
|
payload=RequestStopTransactionPayload(
|
||||||
|
transaction_id=transaction.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if RemoteTransactionStartStopStatus(result.status) != RemoteTransactionStartStopStatus.REJECTED:
|
||||||
|
raise HTTPException(status_code=500, detail=result.status)
|
||||||
|
return RemoteTransactionStartStopResponse(status=result.status)
|
||||||
|
except TimeoutError:
|
||||||
|
raise HTTPException(status_code=503, detail="Chargepoint didn't respond in time.")
|
77
app/routers/user_v1.py
Normal file
77
app/routers/user_v1.py
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
from uuid import UUID
|
||||||
|
from fastapi import APIRouter, HTTPException, Security
|
||||||
|
from fastapi.params import Depends
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.schemas.user import User, UserCreate, UserUpdate
|
||||||
|
from app.models.user import User as DbUser
|
||||||
|
from app.security import get_api_key
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/users",
|
||||||
|
tags=["User (v1)"],
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get(path="", response_model=list[User])
|
||||||
|
async def get_users(
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 20,
|
||||||
|
api_key: str = Security(get_api_key),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
return db.query(DbUser).offset(skip).limit(limit).all()
|
||||||
|
|
||||||
|
@router.get(path="/{user_id}", response_model=User)
|
||||||
|
async def get_user(
|
||||||
|
user_id: UUID,
|
||||||
|
api_key: str = Security(get_api_key),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
user = db.get(DbUser, user_id)
|
||||||
|
if user == None:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
return user
|
||||||
|
|
||||||
|
@router.post(path="", status_code=201, response_model=User)
|
||||||
|
async def create_user(
|
||||||
|
create_user: UserCreate,
|
||||||
|
api_key: str = Security(get_api_key),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
db_user = DbUser(
|
||||||
|
friendly_name=create_user.friendly_name,
|
||||||
|
is_active=create_user.is_active
|
||||||
|
)
|
||||||
|
db.add(db_user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_user)
|
||||||
|
return db_user
|
||||||
|
|
||||||
|
@router.patch(path="/{user_id}", response_model=User)
|
||||||
|
async def update_user(
|
||||||
|
user_id: UUID,
|
||||||
|
user_update: UserUpdate,
|
||||||
|
api_key: str = Security(get_api_key),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
user = db.get(DbUser, user_id)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
for key, value in user_update.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(user, key, value)
|
||||||
|
db.commit()
|
||||||
|
return user
|
||||||
|
|
||||||
|
@router.delete(path="/{user_id}", response_model=None)
|
||||||
|
async def delete_user(
|
||||||
|
user_id: UUID,
|
||||||
|
api_key: str = Security(get_api_key),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
user = db.get(DbUser, user_id)
|
||||||
|
if user == None:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
db.delete(user)
|
||||||
|
db.commit()
|
||||||
|
return []
|
|
@ -6,7 +6,7 @@ from pydantic import BaseModel
|
||||||
|
|
||||||
from app.schemas.connector import Connector
|
from app.schemas.connector import Connector
|
||||||
|
|
||||||
from ocpp.v201.enums import ResetEnumType, ResetStatusEnumType
|
from ocpp.v201.enums import ResetType, ResetStatusType
|
||||||
|
|
||||||
class ChargePointBase(BaseModel):
|
class ChargePointBase(BaseModel):
|
||||||
identity: str
|
identity: str
|
||||||
|
@ -40,8 +40,8 @@ class ChargePointConnectionInfo(BaseModel):
|
||||||
connected: bool
|
connected: bool
|
||||||
|
|
||||||
class ChargePointResetRequest(BaseModel):
|
class ChargePointResetRequest(BaseModel):
|
||||||
type: ResetEnumType
|
type: ResetType
|
||||||
evse_id: Optional[int] = None
|
evse_id: Optional[int] = None
|
||||||
|
|
||||||
class ChargePointResetResponse(BaseModel):
|
class ChargePointResetResponse(BaseModel):
|
||||||
status: ResetStatusEnumType
|
status: ResetStatusType
|
|
@ -3,6 +3,8 @@ from typing import Optional
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.schemas.user import User
|
||||||
|
|
||||||
class IdTokenBase(BaseModel):
|
class IdTokenBase(BaseModel):
|
||||||
friendly_name: str
|
friendly_name: str
|
||||||
is_active: bool
|
is_active: bool
|
20
app/schemas/user.py
Normal file
20
app/schemas/user.py
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
from typing import Optional
|
||||||
|
from uuid import UUID
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
class UserBase(BaseModel):
|
||||||
|
friendly_name: str
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
class UserUpdate(BaseModel):
|
||||||
|
friendly_name: Optional[str] = None
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
|
||||||
|
class UserCreate(UserBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class User(UserBase):
|
||||||
|
id: UUID
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
34
app/security.py
Normal file
34
app/security.py
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
import os
|
||||||
|
from fastapi import HTTPException, Security, status
|
||||||
|
from fastapi.security import APIKeyHeader, HTTPBasic
|
||||||
|
|
||||||
|
basic_auth = HTTPBasic()
|
||||||
|
|
||||||
|
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
|
||||||
|
|
||||||
|
def get_api_key(
|
||||||
|
api_key_header: str = Security(api_key_header),
|
||||||
|
) -> str:
|
||||||
|
"""Retrieve and validate an API key from the HTTP header.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
api_key_header: The API key passed in the HTTP header.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The validated API key.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If the API key is invalid or missing.
|
||||||
|
"""
|
||||||
|
api_key = os.getenv("CS_API_KEY", "default")
|
||||||
|
if api_key == "default":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="API key not set. Authentication not possible.",
|
||||||
|
)
|
||||||
|
if api_key_header == api_key:
|
||||||
|
return api_key_header
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid or missing API Key",
|
||||||
|
)
|
|
@ -1,7 +1,7 @@
|
||||||
from datetime import datetime, UTC
|
from datetime import datetime, UTC
|
||||||
|
|
||||||
from ocpp.v201.datatypes import IdTokenInfoType
|
from ocpp.v201.datatypes import IdTokenInfoType
|
||||||
from ocpp.v201.enums import AuthorizationStatusEnumType
|
from ocpp.v201.enums import AuthorizationStatusType
|
||||||
|
|
||||||
from app.database import SessionLocal
|
from app.database import SessionLocal
|
||||||
from app.models.id_token import IdToken
|
from app.models.id_token import IdToken
|
||||||
|
@ -11,14 +11,14 @@ async def get_id_token_info(chargepoint_id: str, id_token: str):
|
||||||
owner_id = None
|
owner_id = None
|
||||||
if id_token["type"] not in ["ISO14443", "ISO15693"]:
|
if id_token["type"] not in ["ISO14443", "ISO15693"]:
|
||||||
return IdTokenInfoType(
|
return IdTokenInfoType(
|
||||||
status=AuthorizationStatusEnumType.invalid
|
status=AuthorizationStatusType.invalid
|
||||||
), owner_id
|
), owner_id
|
||||||
|
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
db_id_token = db.query(IdToken).filter(IdToken.token == id_token["id_token"]).first()
|
db_id_token = db.query(IdToken).filter(IdToken.token == id_token["id_token"]).first()
|
||||||
if db_id_token == None:
|
if db_id_token == None:
|
||||||
id_token_info = IdTokenInfoType(
|
id_token_info = IdTokenInfoType(
|
||||||
status=AuthorizationStatusEnumType.unknown
|
status=AuthorizationStatusType.unknown
|
||||||
)
|
)
|
||||||
db_chargepoint = db.query(ChargePoint).filter(ChargePoint.identity == chargepoint_id).first()
|
db_chargepoint = db.query(ChargePoint).filter(ChargePoint.identity == chargepoint_id).first()
|
||||||
# Learn token if requested
|
# Learn token if requested
|
||||||
|
@ -32,7 +32,7 @@ async def get_id_token_info(chargepoint_id: str, id_token: str):
|
||||||
db.add(db_id_token)
|
db.add(db_id_token)
|
||||||
|
|
||||||
id_token_info=IdTokenInfoType(
|
id_token_info=IdTokenInfoType(
|
||||||
status=AuthorizationStatusEnumType.accepted
|
status=AuthorizationStatusType.accepted
|
||||||
)
|
)
|
||||||
owner_id = db_id_token.owner_id
|
owner_id = db_id_token.owner_id
|
||||||
db_chargepoint.learn_user_id = None
|
db_chargepoint.learn_user_id = None
|
||||||
|
@ -42,10 +42,10 @@ async def get_id_token_info(chargepoint_id: str, id_token: str):
|
||||||
owner_id = db_id_token.owner_id
|
owner_id = db_id_token.owner_id
|
||||||
if db_id_token.is_active == False:
|
if db_id_token.is_active == False:
|
||||||
id_token_info=IdTokenInfoType(
|
id_token_info=IdTokenInfoType(
|
||||||
status=AuthorizationStatusEnumType.blocked
|
status=AuthorizationStatusType.blocked
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
id_token_info=IdTokenInfoType(
|
id_token_info=IdTokenInfoType(
|
||||||
status=AuthorizationStatusEnumType.accepted
|
status=AuthorizationStatusType.accepted
|
||||||
)
|
)
|
||||||
return id_token_info, owner_id
|
return id_token_info, owner_id
|
|
@ -1,67 +0,0 @@
|
||||||
"""Add user authentication
|
|
||||||
|
|
||||||
Revision ID: c7f72154c90b
|
|
||||||
Revises: 097d427dfa07
|
|
||||||
Create Date: 2025-03-13 14:57:05.805469+00:00
|
|
||||||
|
|
||||||
"""
|
|
||||||
from typing import Sequence, Union
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from sqlalchemy.dialects import postgresql
|
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = 'c7f72154c90b'
|
|
||||||
down_revision: Union[str, None] = '097d427dfa07'
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
op.create_table('sessions',
|
|
||||||
sa.Column('id', sa.Uuid(), nullable=False),
|
|
||||||
sa.Column('name', sa.String(), nullable=True),
|
|
||||||
sa.Column('refresh_token', sa.String(), nullable=False),
|
|
||||||
sa.Column('last_used', sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.Column('user_id', sa.Uuid(), nullable=False),
|
|
||||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
|
||||||
sa.PrimaryKeyConstraint('id')
|
|
||||||
)
|
|
||||||
op.create_index(op.f('ix_sessions_refresh_token'), 'sessions', ['refresh_token'], unique=True)
|
|
||||||
op.create_index(op.f('ix_sessions_user_id'), 'sessions', ['user_id'], unique=False)
|
|
||||||
|
|
||||||
op.add_column('users', sa.Column('email', sa.String(), nullable=True))
|
|
||||||
op.add_column('users', sa.Column('password', sa.String(), nullable=True))
|
|
||||||
|
|
||||||
role_enum = postgresql.ENUM('MEMBER', 'ADMINISTRATOR', name='role')
|
|
||||||
role_enum.create(op.get_bind(), checkfirst=False)
|
|
||||||
op.add_column('users', sa.Column('role', type_=role_enum, nullable=True))
|
|
||||||
|
|
||||||
op.execute('UPDATE users SET email = id || \'@example.com\'')
|
|
||||||
op.execute('UPDATE users SET password = \'invalid\'')
|
|
||||||
op.execute('UPDATE users SET role = \'MEMBER\'')
|
|
||||||
|
|
||||||
with op.batch_alter_table('users', schema=None) as batch_op:
|
|
||||||
batch_op.alter_column('email', nullable=False)
|
|
||||||
batch_op.alter_column('password', nullable=False)
|
|
||||||
batch_op.alter_column('role', nullable=False)
|
|
||||||
|
|
||||||
op.drop_index('ix_users_friendly_name', table_name='users')
|
|
||||||
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
|
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
op.drop_index(op.f('ix_users_email'), table_name='users')
|
|
||||||
op.create_index('ix_users_friendly_name', 'users', ['friendly_name'], unique=1)
|
|
||||||
op.drop_column('users', 'role')
|
|
||||||
op.drop_column('users', 'password')
|
|
||||||
op.drop_column('users', 'email')
|
|
||||||
op.drop_index(op.f('ix_sessions_user_id'), table_name='sessions')
|
|
||||||
op.drop_index(op.f('ix_sessions_refresh_token'), table_name='sessions')
|
|
||||||
op.drop_table('sessions')
|
|
||||||
# ### end Alembic commands ###
|
|
|
@ -1,50 +0,0 @@
|
||||||
"""Add firmware_update table
|
|
||||||
|
|
||||||
Revision ID: 00edfb13e611
|
|
||||||
Revises: c7f72154c90b
|
|
||||||
Create Date: 2025-03-23 14:01:14.029527+00:00
|
|
||||||
|
|
||||||
"""
|
|
||||||
from typing import Sequence, Union
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = '00edfb13e611'
|
|
||||||
down_revision: Union[str, None] = 'c7f72154c90b'
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
op.create_table('firmware_updates',
|
|
||||||
sa.Column('id', sa.Uuid(), nullable=False),
|
|
||||||
sa.Column('request_id', sa.Integer(), nullable=True),
|
|
||||||
sa.Column('status', sa.Enum('CREATED', 'SUBMITTED', 'DOWNLOADED', 'DOWNLOAD_FAILED', 'DOWNLOADING', 'DOWNLOAD_SCHEDULED', 'DOWNLOAD_PAUSED', 'IDLE', 'INSTALLATION_FAILED', 'INSTALLING', 'INSTALLED', 'INSTALL_REBOOTING', 'INSTALL_SCHEDULED', 'INSTALL_VERIFICATION_FAILED', 'INVALID_SIGNATURE', 'SIGNATURE_VERIFIED', name='firmwareupdatestatus'), nullable=True),
|
|
||||||
sa.Column('retries', sa.Integer(), nullable=True),
|
|
||||||
sa.Column('retry_interval', sa.Integer(), nullable=True),
|
|
||||||
sa.Column('location', sa.String(), nullable=True),
|
|
||||||
sa.Column('retrieve_date_time', sa.DateTime(), nullable=True),
|
|
||||||
sa.Column('install_date_time', sa.DateTime(), nullable=True),
|
|
||||||
sa.Column('chargepoint_id', sa.Uuid(), nullable=True),
|
|
||||||
sa.ForeignKeyConstraint(['chargepoint_id'], ['chargepoints.id'], ),
|
|
||||||
sa.PrimaryKeyConstraint('id')
|
|
||||||
)
|
|
||||||
op.create_index(op.f('ix_firmware_updates_chargepoint_id'), 'firmware_updates', ['chargepoint_id'], unique=False)
|
|
||||||
op.alter_column('users', 'is_active',
|
|
||||||
existing_type=sa.BOOLEAN(),
|
|
||||||
nullable=False)
|
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
op.alter_column('users', 'is_active',
|
|
||||||
existing_type=sa.BOOLEAN(),
|
|
||||||
nullable=True)
|
|
||||||
op.drop_index(op.f('ix_firmware_updates_chargepoint_id'), table_name='firmware_updates')
|
|
||||||
op.drop_table('firmware_updates')
|
|
||||||
# ### end Alembic commands ###
|
|
|
@ -1,32 +0,0 @@
|
||||||
"""Add signature fields to firmware_update table
|
|
||||||
|
|
||||||
Revision ID: 506cc8d086c9
|
|
||||||
Revises: 00edfb13e611
|
|
||||||
Create Date: 2025-03-23 14:49:42.662564+00:00
|
|
||||||
|
|
||||||
"""
|
|
||||||
from typing import Sequence, Union
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = '506cc8d086c9'
|
|
||||||
down_revision: Union[str, None] = '00edfb13e611'
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
op.add_column('firmware_updates', sa.Column('signing_certificate', sa.Text(), nullable=True))
|
|
||||||
op.add_column('firmware_updates', sa.Column('signature', sa.String(), nullable=True))
|
|
||||||
# ### end Alembic commands ###
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
# ### commands auto generated by Alembic - please adjust! ###
|
|
||||||
op.drop_column('firmware_updates', 'signature')
|
|
||||||
op.drop_column('firmware_updates', 'signing_certificate')
|
|
||||||
# ### end Alembic commands ###
|
|
|
@ -1,87 +0,0 @@
|
||||||
import secrets
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
sys.path.append(os.path.join(os.path.dirname(__file__), ".."))
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from argon2 import PasswordHasher
|
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
from app.models import *
|
|
||||||
from app.database import SessionLocal
|
|
||||||
|
|
||||||
def __get_user_by_email(db: Session, email: str):
|
|
||||||
stmt = select(user.User).where(user.User.email == email)
|
|
||||||
result = db.execute(stmt)
|
|
||||||
return result.scalars().first()
|
|
||||||
|
|
||||||
def create_user(args):
|
|
||||||
hasher = PasswordHasher(memory_cost=102400)
|
|
||||||
with SessionLocal() as db:
|
|
||||||
db_user = __get_user_by_email(db, args.email)
|
|
||||||
if db_user is not None:
|
|
||||||
print(f'Error: A user with email \'{args.email}\' already exists.')
|
|
||||||
sys.exit(1)
|
|
||||||
hashed_password = hasher.hash(args.password)
|
|
||||||
db_user = user.User(
|
|
||||||
friendly_name=args.name, email=args.email, password=hashed_password
|
|
||||||
)
|
|
||||||
db.add(db_user)
|
|
||||||
db.commit()
|
|
||||||
print(f'Success: Created user \'{args.email}\'.')
|
|
||||||
|
|
||||||
def reset_password(args):
|
|
||||||
hasher = PasswordHasher(memory_cost=102400)
|
|
||||||
with SessionLocal() as db:
|
|
||||||
db_user = __get_user_by_email(db, args.email)
|
|
||||||
if db_user is None:
|
|
||||||
print(f'Error: No user with email \'{args.email}\' found.')
|
|
||||||
sys.exit(1)
|
|
||||||
db_user.password = hasher.hash(args.password)
|
|
||||||
db.commit()
|
|
||||||
print(f'Success: Changed password for user \'{args.email}\'.')
|
|
||||||
|
|
||||||
def set_role(args):
|
|
||||||
with SessionLocal() as db:
|
|
||||||
db_user = __get_user_by_email(db, args.email)
|
|
||||||
if db_user is None:
|
|
||||||
print(f'Error: No user with email \'{args.email}\' found.')
|
|
||||||
sys.exit(1)
|
|
||||||
db_user.role = user.Role(args.role)
|
|
||||||
db.commit()
|
|
||||||
print(f'Success: Role of user \'{args.email}\' changed to \'{db_user.role}\'.')
|
|
||||||
|
|
||||||
def generate_secret(args):
|
|
||||||
print(f'Your secret: {secrets.token_urlsafe(64)}')
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
subparsers = parser.add_subparsers(help="subcommands", required=True)
|
|
||||||
|
|
||||||
p_create_user = subparsers.add_parser('user_create', help='Create a new user')
|
|
||||||
p_create_user.add_argument('--email', type=str, required=True, help='Email of the new user')
|
|
||||||
p_create_user.add_argument('--password', type=str, required=True, help='Password for the new user')
|
|
||||||
p_create_user.add_argument('--name', type=str, required=False, help='Display name of the new user')
|
|
||||||
p_create_user.add_argument('--role', type=str, choices=('member', 'administrator'), default='member', required=False, help='Role of the new user')
|
|
||||||
p_create_user.set_defaults(func=create_user)
|
|
||||||
|
|
||||||
p_reset_password = subparsers.add_parser('user_reset_password', help='Reset password for a user')
|
|
||||||
p_reset_password.add_argument('--email', type=str, required=True, help='Email of the user to modify')
|
|
||||||
p_reset_password.add_argument('--password', type=str, required=True, help='New password for the specified user')
|
|
||||||
p_reset_password.set_defaults(func=reset_password)
|
|
||||||
|
|
||||||
p_set_role = subparsers.add_parser('user_set_role', help='Update role of a user')
|
|
||||||
p_set_role.add_argument('--email', type=str, required=True, help='Email of the user to modify')
|
|
||||||
p_set_role.add_argument('--role', type=str, choices=('member', 'administrator'), required=True, help='New role for the specified user')
|
|
||||||
p_set_role.set_defaults(func=set_role)
|
|
||||||
|
|
||||||
p_set_role = subparsers.add_parser('generate_secret', help='Generate safe to use secret for token signing')
|
|
||||||
p_set_role.set_defaults(func=generate_secret)
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
args.func(args)
|
|
|
@ -1,52 +0,0 @@
|
||||||
from dotenv import load_dotenv
|
|
||||||
from fastapi import APIRouter, FastAPI
|
|
||||||
from starlette.middleware.authentication import AuthenticationMiddleware
|
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
from app.routers import (
|
|
||||||
auth_v1,
|
|
||||||
chargepoint_v1,
|
|
||||||
id_token_v1,
|
|
||||||
me_v1,
|
|
||||||
meter_value_v1,
|
|
||||||
ocpp_v1,
|
|
||||||
transaction_v1,
|
|
||||||
user_v1
|
|
||||||
)
|
|
||||||
from app.security.websocket_auth_backend import BasicAuthBackend
|
|
||||||
|
|
||||||
def create_ocpp_app():
|
|
||||||
app_ocpp = FastAPI(
|
|
||||||
responses={404: {"description": "Not found"}},
|
|
||||||
)
|
|
||||||
app_ocpp.include_router(ocpp_v1.router)
|
|
||||||
app_ocpp.add_middleware(AuthenticationMiddleware, backend=BasicAuthBackend())
|
|
||||||
|
|
||||||
return app_ocpp
|
|
||||||
|
|
||||||
def create_app():
|
|
||||||
app = FastAPI(
|
|
||||||
title="simple-ocpp-cs",
|
|
||||||
version="0.2.0",
|
|
||||||
summary="Simple implementation of a basic OCPP 2.0.1 compliant central system (backend) for EV charging stations",
|
|
||||||
responses={404: {"description": "Not found"}},
|
|
||||||
)
|
|
||||||
|
|
||||||
api_v1_router = APIRouter(
|
|
||||||
prefix="/api/v1"
|
|
||||||
)
|
|
||||||
api_v1_router.include_router(auth_v1.router)
|
|
||||||
api_v1_router.include_router(chargepoint_v1.router)
|
|
||||||
api_v1_router.include_router(id_token_v1.router)
|
|
||||||
api_v1_router.include_router(me_v1.router)
|
|
||||||
api_v1_router.include_router(user_v1.router)
|
|
||||||
api_v1_router.include_router(meter_value_v1.router)
|
|
||||||
api_v1_router.include_router(transaction_v1.router)
|
|
||||||
|
|
||||||
app.include_router(api_v1_router)
|
|
||||||
app.mount(path="/v1/ocpp", app=create_ocpp_app())
|
|
||||||
|
|
||||||
return app
|
|
||||||
|
|
||||||
app = create_app()
|
|
|
@ -1,22 +0,0 @@
|
||||||
import uuid
|
|
||||||
from sqlalchemy import Column, DateTime, Enum, ForeignKey, Integer, String, Text, Uuid
|
|
||||||
|
|
||||||
from app.database import Base
|
|
||||||
from app.schemas.firmware_update import FirmwareUpdateStatus
|
|
||||||
|
|
||||||
class FirmwareUpdate(Base):
|
|
||||||
__tablename__ = "firmware_updates"
|
|
||||||
|
|
||||||
id = Column(Uuid, primary_key=True, default=uuid.uuid4)
|
|
||||||
request_id = Column(Integer)
|
|
||||||
status = Column(Enum(FirmwareUpdateStatus))
|
|
||||||
|
|
||||||
retries = Column(Integer)
|
|
||||||
retry_interval = Column(Integer)
|
|
||||||
location = Column(String)
|
|
||||||
retrieve_date_time = Column(DateTime)
|
|
||||||
install_date_time = Column(DateTime, nullable=True)
|
|
||||||
signing_certificate = Column(Text, nullable=True)
|
|
||||||
signature = Column(String, nullable=True)
|
|
||||||
|
|
||||||
chargepoint_id = Column(Uuid, ForeignKey("chargepoints.id"), index=True)
|
|
|
@ -1,14 +0,0 @@
|
||||||
import uuid
|
|
||||||
from sqlalchemy import Column, DateTime, ForeignKey, String, Uuid
|
|
||||||
|
|
||||||
from app.database import Base
|
|
||||||
|
|
||||||
class Session(Base):
|
|
||||||
__tablename__ = "sessions"
|
|
||||||
|
|
||||||
id = Column(Uuid, primary_key=True, default=uuid.uuid4)
|
|
||||||
name = Column(String)
|
|
||||||
refresh_token = Column(String, nullable=False, unique=True, index=True)
|
|
||||||
last_used = Column(DateTime(timezone=True))
|
|
||||||
|
|
||||||
user_id = Column(Uuid, ForeignKey("users.id"), nullable=False, index=True)
|
|
|
@ -1,19 +0,0 @@
|
||||||
import uuid
|
|
||||||
from sqlalchemy import Enum, Uuid, Boolean, Column, String
|
|
||||||
from sqlalchemy.orm import relationship
|
|
||||||
|
|
||||||
from app.database import Base
|
|
||||||
from app.schemas.user import Role
|
|
||||||
|
|
||||||
class User(Base):
|
|
||||||
__tablename__ = "users"
|
|
||||||
|
|
||||||
id = Column(Uuid, primary_key=True, default=uuid.uuid4)
|
|
||||||
friendly_name = Column(String, nullable=True)
|
|
||||||
is_active = Column(Boolean, nullable=False, default=True)
|
|
||||||
email = Column(String, nullable=False, unique=True, index=True)
|
|
||||||
password = Column(String, nullable=False)
|
|
||||||
role = Column(Enum(Role), nullable=False, default=Role.MEMBER)
|
|
||||||
|
|
||||||
id_tokens = relationship("IdToken", back_populates="owner", cascade="delete, delete-orphan")
|
|
||||||
transactions = relationship("Transaction", cascade="delete, delete-orphan")
|
|
|
@ -1,72 +0,0 @@
|
||||||
from uuid import UUID
|
|
||||||
from fastapi import APIRouter, HTTPException
|
|
||||||
from fastapi.params import Depends
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from app.database import get_db
|
|
||||||
from app.schemas.auth_token import (
|
|
||||||
AccessToken,
|
|
||||||
TokenRefreshRequest,
|
|
||||||
TokenResponse,
|
|
||||||
)
|
|
||||||
from app.schemas.user import LoginRequest
|
|
||||||
from app.security.jwt_bearer import JWTBearer
|
|
||||||
from app.services import session_service, token_service, user_service
|
|
||||||
from app.util.errors import NotFoundError
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["Authentication (v1)"])
|
|
||||||
|
|
||||||
@router.post(path="/login", response_model=TokenResponse)
|
|
||||||
async def login(
|
|
||||||
login_request: LoginRequest, db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Login to a existing account. Creates a new session and returns a access and refresh token.
|
|
||||||
"""
|
|
||||||
user = await user_service.validate_login(
|
|
||||||
db=db, login=login_request
|
|
||||||
)
|
|
||||||
if not user:
|
|
||||||
raise HTTPException(status_code=403, detail="invalid_email_or_password")
|
|
||||||
session = await session_service.create_session(db=db, user=user, useragent="")
|
|
||||||
token, expire = await token_service.create_access_token(
|
|
||||||
user=user, session_id=session.id
|
|
||||||
)
|
|
||||||
return TokenResponse(
|
|
||||||
access_token=token, refresh_token=session.refresh_token, not_after=expire
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(path="/logout", response_model=list[None])
|
|
||||||
async def logout(
|
|
||||||
db: Session = Depends(get_db), token: AccessToken = Depends(JWTBearer())
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Remove the current session based on the access token, effectively invalidating the current refresh token.
|
|
||||||
"""
|
|
||||||
await session_service.remove_session(
|
|
||||||
db=db, id=UUID(token.session)
|
|
||||||
)
|
|
||||||
return list()
|
|
||||||
|
|
||||||
@router.post(path="/refresh", response_model=TokenResponse)
|
|
||||||
async def refresh_access_token(
|
|
||||||
token_request: TokenRefreshRequest,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Use an existing refresh token to generate a new access token and a new refresh token.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
session = await session_service.validate_and_rotate_refresh_token(
|
|
||||||
db=db, refresh_token=token_request.refresh_token
|
|
||||||
)
|
|
||||||
user = await user_service.get_user(db=db, id=session.user_id)
|
|
||||||
token, expire = await token_service.create_access_token(
|
|
||||||
user=user, session_id=session.id
|
|
||||||
)
|
|
||||||
return TokenResponse(
|
|
||||||
access_token=token, refresh_token=session.refresh_token, not_after=expire
|
|
||||||
)
|
|
||||||
except NotFoundError:
|
|
||||||
raise HTTPException(status_code=403, detail="invalid_refresh_token")
|
|
|
@ -1,111 +0,0 @@
|
||||||
from uuid import UUID
|
|
||||||
from fastapi import APIRouter, HTTPException
|
|
||||||
from fastapi.params import Depends
|
|
||||||
from sqlalchemy.orm import Session as DbSession
|
|
||||||
|
|
||||||
from app.database import get_db
|
|
||||||
from app.schemas.session import Session
|
|
||||||
from app.schemas.auth_token import AccessToken
|
|
||||||
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
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/me", tags=["Me (v1)"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(path="", response_model=User)
|
|
||||||
async def get_myself(
|
|
||||||
db: DbSession = Depends(get_db), token: AccessToken = Depends(JWTBearer())
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Get the currently authenticated user.
|
|
||||||
"""
|
|
||||||
user = await user_service.get_user(db=db, id=UUID(token.subject))
|
|
||||||
if not user:
|
|
||||||
raise HTTPException(status_code=404, detail="user_not_found")
|
|
||||||
else:
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch(path="", response_model=User)
|
|
||||||
async def update_myself(
|
|
||||||
user_update: UserUpdate,
|
|
||||||
db: DbSession = Depends(get_db),
|
|
||||||
token: AccessToken = Depends(JWTBearer()),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Update the currently authenticated user. Changing the email address automatically marks it as not verified
|
|
||||||
and starts a new verification workflow.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return await user_service.update_user(
|
|
||||||
db, UUID(token.subject), user_update
|
|
||||||
)
|
|
||||||
except NotFoundError:
|
|
||||||
raise HTTPException(status_code=404, detail="user_not_found")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(path="/password", response_model=list[None])
|
|
||||||
async def change_password(
|
|
||||||
update: PasswordUpdate,
|
|
||||||
db: DbSession = Depends(get_db),
|
|
||||||
token: AccessToken = Depends(JWTBearer()),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Change the password of the currently authenticated user.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
await user_service.change_user_password(
|
|
||||||
db=db, id=UUID(token.subject), update=update
|
|
||||||
)
|
|
||||||
return list()
|
|
||||||
except NotFoundError:
|
|
||||||
raise HTTPException(status_code=404, detail="user_not_found")
|
|
||||||
except InvalidStateError:
|
|
||||||
raise HTTPException(status_code=409, detail="incorrect_password")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(path="/sessions", response_model=list[Session])
|
|
||||||
async def get_user_sessions(
|
|
||||||
db: DbSession = Depends(get_db), token: AccessToken = Depends(JWTBearer())
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
List the active sessions of the currently authenticated user.
|
|
||||||
"""
|
|
||||||
return await session_service.get_sessions_by_user(
|
|
||||||
db=db, user_id=UUID(token.subject)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete(path="/sessions", response_model=list[None])
|
|
||||||
async def clear_user_sessions(
|
|
||||||
db: DbSession = Depends(get_db), token: AccessToken = Depends(JWTBearer())
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Clear all sessions of the currently authenticated user.
|
|
||||||
"""
|
|
||||||
await session_service.remove_all_sessions_for_user(
|
|
||||||
db=db, user_id=UUID(token.subject),
|
|
||||||
)
|
|
||||||
return list()
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete(path="/sessions/{session_id}", response_model=list[None])
|
|
||||||
async def delete_user_session(
|
|
||||||
session_id: UUID,
|
|
||||||
db: DbSession = Depends(get_db),
|
|
||||||
token: AccessToken = Depends(JWTBearer()),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Invalidate a specific session of the currently authenticated user.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
await session_service.remove_session_for_user(
|
|
||||||
db=db,
|
|
||||||
id=session_id,
|
|
||||||
user_id=UUID(token.subject),
|
|
||||||
)
|
|
||||||
except NotFoundError:
|
|
||||||
raise HTTPException(status_code=404, detail="session_not_found")
|
|
||||||
return list()
|
|
|
@ -1,100 +0,0 @@
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from ocpp.v201.call import RequestStopTransaction
|
|
||||||
|
|
||||||
from app.ocpp_proto import chargepoint_manager
|
|
||||||
from app.schemas.auth_token import AccessToken
|
|
||||||
from app.database import get_db
|
|
||||||
from app.schemas.meter_value import MeterValue
|
|
||||||
from app.schemas.transaction import Transaction, RemoteTransactionStartStopResponse, TransactionStatus, RemoteTransactionStartStopStatus
|
|
||||||
from app.models.transaction import Transaction as DbTransaction
|
|
||||||
from app.models.meter_value import MeterValue as DbMeterValue
|
|
||||||
from app.schemas.user import Role
|
|
||||||
from app.security.jwt_bearer import JWTBearer
|
|
||||||
|
|
||||||
router = APIRouter(
|
|
||||||
prefix="/transactions",
|
|
||||||
tags=["Transaction (v1)"]
|
|
||||||
)
|
|
||||||
|
|
||||||
@router.get(path="", response_model=list[Transaction])
|
|
||||||
async def get_transactions(
|
|
||||||
skip: int = 0,
|
|
||||||
limit: int = 20,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
token: AccessToken = Depends(JWTBearer()),
|
|
||||||
):
|
|
||||||
stmt = select(DbTransaction)
|
|
||||||
if (token.role != Role.ADMINISTRATOR):
|
|
||||||
stmt = stmt.where(DbTransaction.user_id == token.subject)
|
|
||||||
stmt = stmt.order_by(DbTransaction.started_at).offset(skip).limit(limit)
|
|
||||||
result = db.execute(stmt)
|
|
||||||
return result.scalars().all()
|
|
||||||
|
|
||||||
@router.get(path="/{transaction_id}", response_model=Transaction)
|
|
||||||
async def get_transaction(
|
|
||||||
transaction_id: str,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
token: AccessToken = Depends(JWTBearer()),
|
|
||||||
):
|
|
||||||
stmt = select(DbTransaction).where(DbTransaction.id == transaction_id)
|
|
||||||
result = db.execute(stmt)
|
|
||||||
transaction = result.scalars().first()
|
|
||||||
if transaction == None:
|
|
||||||
raise HTTPException(404, "Transaction not found")
|
|
||||||
if token.role != Role.ADMINISTRATOR and transaction.user_id != token.subject:
|
|
||||||
raise HTTPException(404, "Transaction not found")
|
|
||||||
return transaction
|
|
||||||
|
|
||||||
@router.get(path="/{transaction_id}/meter-values", response_model=list[MeterValue])
|
|
||||||
async def get_transaction_meter_values(
|
|
||||||
transaction_id: str,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
token: AccessToken = Depends(JWTBearer()),
|
|
||||||
):
|
|
||||||
stmt = select(DbTransaction).where(DbTransaction.id == transaction_id)
|
|
||||||
result = db.execute(stmt)
|
|
||||||
transaction = result.scalars().first()
|
|
||||||
if transaction == None:
|
|
||||||
raise HTTPException(404, "Transaction not found")
|
|
||||||
if token.role != Role.ADMINISTRATOR and transaction.user_id != token.subject:
|
|
||||||
raise HTTPException(404, "Transaction not found")
|
|
||||||
stmt = select(DbMeterValue).where(DbMeterValue.transaction_id == transaction_id).order_by(DbMeterValue.timestamp)
|
|
||||||
result = db.execute(stmt)
|
|
||||||
return result.scalars().all()
|
|
||||||
|
|
||||||
@router.post(path="/{transaction_id}/remote-stop", response_model=RemoteTransactionStartStopResponse)
|
|
||||||
async def remote_stop_transaction(
|
|
||||||
transaction_id: str,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
token: AccessToken = Depends(JWTBearer()),
|
|
||||||
):
|
|
||||||
stmt = select(DbTransaction).where(DbTransaction.id == transaction_id)
|
|
||||||
result = db.execute(stmt)
|
|
||||||
transaction = result.scalars().first()
|
|
||||||
if transaction == None:
|
|
||||||
raise HTTPException(404, "Transaction not found")
|
|
||||||
if token.role != Role.ADMINISTRATOR and transaction.user_id != token.subject:
|
|
||||||
raise HTTPException(404, "Transaction not found")
|
|
||||||
if transaction.status != TransactionStatus.ONGOING:
|
|
||||||
raise HTTPException(status_code=422, detail=[{
|
|
||||||
"loc": ["path", "transaction_id"],
|
|
||||||
"msg": "Transaction is not ongoing",
|
|
||||||
"type": "invalid_transaction_state"
|
|
||||||
}])
|
|
||||||
if chargepoint_manager.is_connected(transaction.chargepoint_id) == False:
|
|
||||||
raise HTTPException(status_code=503, detail="chargepoint_offline")
|
|
||||||
try:
|
|
||||||
result = await chargepoint_manager.call(
|
|
||||||
transaction.chargepoint_id,
|
|
||||||
payload=RequestStopTransaction(
|
|
||||||
transaction_id=transaction.id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if RemoteTransactionStartStopStatus(result.status) != RemoteTransactionStartStopStatus.REJECTED:
|
|
||||||
raise HTTPException(status_code=500, detail=result.status)
|
|
||||||
return RemoteTransactionStartStopResponse(status=result.status)
|
|
||||||
except TimeoutError:
|
|
||||||
raise HTTPException(status_code=503, detail="chargepoint_operation_timeout")
|
|
|
@ -1,116 +0,0 @@
|
||||||
from uuid import UUID
|
|
||||||
from fastapi import APIRouter, HTTPException
|
|
||||||
from fastapi.params import Depends
|
|
||||||
from sqlalchemy.orm import Session as DbSession
|
|
||||||
|
|
||||||
from app.database import get_db
|
|
||||||
from app.schemas.session import Session
|
|
||||||
from app.schemas.auth_token import AccessToken
|
|
||||||
from app.schemas.user import AdministrativeUserUpdate, User, UserCreate
|
|
||||||
from app.security.jwt_bearer import JWTBearer
|
|
||||||
from app.services import session_service, user_service
|
|
||||||
from app.util.errors import NotFoundError
|
|
||||||
|
|
||||||
router = APIRouter(
|
|
||||||
prefix="/users",
|
|
||||||
tags=["User (v1)"],
|
|
||||||
)
|
|
||||||
|
|
||||||
@router.get(path="", response_model=list[User])
|
|
||||||
async def get_users(
|
|
||||||
email: str = None,
|
|
||||||
skip: int = 0,
|
|
||||||
limit: int = 20,
|
|
||||||
db: DbSession = Depends(get_db),
|
|
||||||
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
|
|
||||||
):
|
|
||||||
return await user_service.get_users(db, skip, limit, email)
|
|
||||||
|
|
||||||
@router.post(path="", status_code=201, response_model=User)
|
|
||||||
async def create_user(
|
|
||||||
create_user: UserCreate,
|
|
||||||
db: DbSession = Depends(get_db),
|
|
||||||
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
|
|
||||||
):
|
|
||||||
user = await user_service.create_user(
|
|
||||||
db=db, user=create_user
|
|
||||||
)
|
|
||||||
return user
|
|
||||||
|
|
||||||
@router.patch(path="/{user_id}", response_model=User)
|
|
||||||
async def update_user(
|
|
||||||
user_id: UUID,
|
|
||||||
user_update: AdministrativeUserUpdate,
|
|
||||||
db: DbSession = Depends(get_db),
|
|
||||||
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
return await user_service.update_user(
|
|
||||||
db, user_id, user_update
|
|
||||||
)
|
|
||||||
except NotFoundError:
|
|
||||||
raise HTTPException(status_code=404, detail="user_not_found")
|
|
||||||
|
|
||||||
@router.delete(path="/{user_id}", response_model=None)
|
|
||||||
async def delete_user(
|
|
||||||
user_id: UUID,
|
|
||||||
db: DbSession = Depends(get_db),
|
|
||||||
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
await user_service.remove_user(db, user_id)
|
|
||||||
return list()
|
|
||||||
except NotFoundError:
|
|
||||||
raise HTTPException(status_code=404, detail="user_not_found")
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
path="/{user_id}/sessions", response_model=list[Session]
|
|
||||||
)
|
|
||||||
async def get_user_sessions(
|
|
||||||
user_id: UUID,
|
|
||||||
db: DbSession = Depends(get_db),
|
|
||||||
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Query sessions of the specified user. Requires the "administrator" role.
|
|
||||||
"""
|
|
||||||
return await session_service.get_sessions_by_user(db=db, user_id=user_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
|
||||||
path="/{user_id}/sessions", response_model=list[None]
|
|
||||||
)
|
|
||||||
async def remove_all_user_session(
|
|
||||||
user_id: UUID,
|
|
||||||
db: DbSession = Depends(get_db),
|
|
||||||
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Delete all sessions of the specified user. Requires the "administrator" role.
|
|
||||||
"""
|
|
||||||
await session_service.remove_all_sessions_for_user(
|
|
||||||
db=db, user_id=user_id
|
|
||||||
)
|
|
||||||
return list()
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
|
||||||
path="/{user_id}/sessions/{session_id}",
|
|
||||||
response_model=list[None],
|
|
||||||
)
|
|
||||||
async def remove_user_session(
|
|
||||||
user_id: UUID,
|
|
||||||
session_id: UUID,
|
|
||||||
db: DbSession = Depends(get_db),
|
|
||||||
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Delete the specified session of the specified user. Requires the "administrator" role.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
await session_service.remove_session_for_user(
|
|
||||||
db=db, id=session_id, user_id=user_id
|
|
||||||
)
|
|
||||||
except NotFoundError:
|
|
||||||
raise HTTPException(status_code=404, detail="session_not_found")
|
|
||||||
return list()
|
|
|
@ -1,21 +0,0 @@
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from app.schemas.user import Role
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class AccessToken:
|
|
||||||
subject: str
|
|
||||||
role: Role
|
|
||||||
session: str
|
|
||||||
|
|
||||||
class TokenRefreshRequest(BaseModel):
|
|
||||||
refresh_token: str
|
|
||||||
|
|
||||||
|
|
||||||
class TokenResponse(BaseModel):
|
|
||||||
access_token: str
|
|
||||||
refresh_token: str
|
|
||||||
not_after: datetime
|
|
|
@ -1,43 +0,0 @@
|
||||||
from datetime import datetime
|
|
||||||
import enum
|
|
||||||
from typing import Optional
|
|
||||||
from uuid import UUID
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
class FirmwareUpdateStatus(enum.Enum):
|
|
||||||
CREATED = "xCreated"
|
|
||||||
SUBMITTED = "xSubmitted"
|
|
||||||
DOWNLOADED = "Downloaded"
|
|
||||||
DOWNLOAD_FAILED = "DownloadFailed"
|
|
||||||
DOWNLOADING = "Downloading"
|
|
||||||
DOWNLOAD_SCHEDULED = "DownloadScheduled"
|
|
||||||
DOWNLOAD_PAUSED = "DownloadPaused"
|
|
||||||
IDLE = "Idle"
|
|
||||||
INSTALLATION_FAILED = "InstallationFailed"
|
|
||||||
INSTALLING = "Installing"
|
|
||||||
INSTALLED = "Installed"
|
|
||||||
INSTALL_REBOOTING = "InstallRebooting"
|
|
||||||
INSTALL_SCHEDULED = "InstallScheduled"
|
|
||||||
INSTALL_VERIFICATION_FAILED = "InstallVerificationFailed"
|
|
||||||
INVALID_SIGNATURE = "InvalidSignature"
|
|
||||||
SIGNATURE_VERIFIED = "SignatureVerified"
|
|
||||||
|
|
||||||
class FirmwareUpdateBase(BaseModel):
|
|
||||||
retries: int
|
|
||||||
retry_interval: int
|
|
||||||
location: str
|
|
||||||
retrieve_date_time: datetime
|
|
||||||
install_date_time: Optional[datetime]
|
|
||||||
signing_certificate: Optional[str]
|
|
||||||
signature: Optional[str]
|
|
||||||
|
|
||||||
class FirmwareUpdate(FirmwareUpdateBase):
|
|
||||||
id: UUID
|
|
||||||
request_id: int
|
|
||||||
status: FirmwareUpdateStatus
|
|
||||||
|
|
||||||
class FirmwareUpdateCreate(FirmwareUpdateBase):
|
|
||||||
pass
|
|
||||||
|
|
||||||
class FirmwareUpdateSubmissionResponse(BaseModel):
|
|
||||||
status: str
|
|
|
@ -1,11 +0,0 @@
|
||||||
from datetime import datetime
|
|
||||||
from uuid import UUID
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
|
|
||||||
class Session(BaseModel):
|
|
||||||
id: UUID
|
|
||||||
name: str
|
|
||||||
last_used: datetime
|
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
|
|
@ -1,39 +0,0 @@
|
||||||
import enum
|
|
||||||
from typing import Optional
|
|
||||||
from uuid import UUID
|
|
||||||
from pydantic import BaseModel, EmailStr, Field
|
|
||||||
|
|
||||||
class Role(enum.StrEnum):
|
|
||||||
MEMBER = "member"
|
|
||||||
ADMINISTRATOR = "administrator"
|
|
||||||
|
|
||||||
class UserBase(BaseModel):
|
|
||||||
email: EmailStr = Field(max_length=60)
|
|
||||||
friendly_name: str
|
|
||||||
is_active: bool
|
|
||||||
|
|
||||||
class UserUpdate(BaseModel):
|
|
||||||
email: Optional[str] = None
|
|
||||||
friendly_name: Optional[str] = None
|
|
||||||
|
|
||||||
class AdministrativeUserUpdate(UserUpdate):
|
|
||||||
is_active: Optional[bool] = None
|
|
||||||
|
|
||||||
class UserCreate(UserBase):
|
|
||||||
password: str = Field(max_length=100)
|
|
||||||
pass
|
|
||||||
|
|
||||||
class User(UserBase):
|
|
||||||
id: UUID
|
|
||||||
role: Role
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
from_attributes = True
|
|
||||||
|
|
||||||
class PasswordUpdate(BaseModel):
|
|
||||||
old_password: str = Field(max_length=100)
|
|
||||||
new_password: str = Field(max_length=100)
|
|
||||||
|
|
||||||
class LoginRequest(BaseModel):
|
|
||||||
email: EmailStr = Field(max_length=60)
|
|
||||||
password: str = Field(max_length=100)
|
|
|
@ -1,47 +0,0 @@
|
||||||
from typing import Optional
|
|
||||||
from fastapi import Request, HTTPException
|
|
||||||
from fastapi.params import Depends
|
|
||||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.database import get_db
|
|
||||||
from app.services import token_service
|
|
||||||
from app.util.errors import InsufficientPermissionsError, InvalidTokenAudienceError
|
|
||||||
from app.schemas.auth_token import AccessToken
|
|
||||||
|
|
||||||
|
|
||||||
class JWTBearer(HTTPBearer):
|
|
||||||
__required_roles: list[str] | None
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self, required_roles: Optional[list[str]] = None, auto_error: bool = True
|
|
||||||
):
|
|
||||||
self.__required_roles = required_roles
|
|
||||||
super(JWTBearer, self).__init__(auto_error=auto_error)
|
|
||||||
|
|
||||||
async def __call__(
|
|
||||||
self, request: Request, db: AsyncSession = Depends(get_db)
|
|
||||||
) -> AccessToken:
|
|
||||||
credentials: HTTPAuthorizationCredentials | None = await super(
|
|
||||||
JWTBearer, self
|
|
||||||
).__call__(request)
|
|
||||||
if credentials:
|
|
||||||
if not credentials.scheme == "Bearer":
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=403, detail="authentication_scheme_invalid"
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
token = await token_service.verify_access_token(
|
|
||||||
credentials.credentials, self.__required_roles
|
|
||||||
)
|
|
||||||
if not token:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=403, detail="token_invalid_or_expired"
|
|
||||||
)
|
|
||||||
return token
|
|
||||||
except InsufficientPermissionsError:
|
|
||||||
raise HTTPException(status_code=403, detail="insufficient_permissions")
|
|
||||||
except InvalidTokenAudienceError:
|
|
||||||
raise HTTPException(status_code=403, detail="invalid_token_audience")
|
|
||||||
else:
|
|
||||||
raise HTTPException(status_code=403, detail="authorization_code_invalid")
|
|
|
@ -1,66 +0,0 @@
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from ocpp.v201.call import UpdateFirmware
|
|
||||||
from ocpp.v201.call_result import UpdateFirmware as UpdateFirmwareResult
|
|
||||||
from ocpp.v201.datatypes import FirmwareType
|
|
||||||
|
|
||||||
from app.database import SessionLocal
|
|
||||||
from app.models.chargepoint import ChargePoint
|
|
||||||
from app.models.firmware_update import FirmwareUpdate
|
|
||||||
from app.ocpp_proto import chargepoint_manager
|
|
||||||
from app.schemas.firmware_update import FirmwareUpdateCreate, FirmwareUpdateStatus
|
|
||||||
|
|
||||||
async def create_firmware_update(chargepoint_id: UUID, firmware_update: FirmwareUpdateCreate) -> FirmwareUpdate:
|
|
||||||
with SessionLocal() as db:
|
|
||||||
db_chargepoint = db.get(ChargePoint, chargepoint_id)
|
|
||||||
latest_firmware_update = db.query(FirmwareUpdate).filter(FirmwareUpdate.chargepoint_id == db_chargepoint.id).order_by(FirmwareUpdate.request_id.desc()).first()
|
|
||||||
new_request_id = latest_firmware_update.request_id + 1 if latest_firmware_update else 1
|
|
||||||
db_firmware_update = FirmwareUpdate(
|
|
||||||
request_id=new_request_id,
|
|
||||||
status=FirmwareUpdateStatus.CREATED,
|
|
||||||
retries=firmware_update.retries,
|
|
||||||
retry_interval=firmware_update.retry_interval,
|
|
||||||
location=firmware_update.location,
|
|
||||||
retrieve_date_time=firmware_update.retrieve_date_time,
|
|
||||||
install_date_time=firmware_update.install_date_time,
|
|
||||||
chargepoint_id=db_chargepoint.id,
|
|
||||||
signing_certificate=firmware_update.signing_certificate,
|
|
||||||
signature=firmware_update.signature
|
|
||||||
)
|
|
||||||
db.add(db_firmware_update)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(db_firmware_update)
|
|
||||||
return db_firmware_update
|
|
||||||
|
|
||||||
async def submit_firmware_update(firmware_update_id: UUID) -> tuple[FirmwareUpdate, str]:
|
|
||||||
with SessionLocal() as db:
|
|
||||||
db_firmware_update = db.get(FirmwareUpdate, firmware_update_id)
|
|
||||||
try:
|
|
||||||
result: UpdateFirmwareResult = await chargepoint_manager.call(
|
|
||||||
db_firmware_update.chargepoint_id,
|
|
||||||
payload=UpdateFirmware(
|
|
||||||
request_id=db_firmware_update.request_id,
|
|
||||||
retries=db_firmware_update.retries,
|
|
||||||
retry_interval=db_firmware_update.retry_interval,
|
|
||||||
firmware=FirmwareType(
|
|
||||||
location=db_firmware_update.location,
|
|
||||||
retrieve_date_time=db_firmware_update.retrieve_date_time.isoformat(),
|
|
||||||
install_date_time=db_firmware_update.install_date_time.isoformat(),
|
|
||||||
signing_certificate=db_firmware_update.signing_certificate,
|
|
||||||
signature=db_firmware_update.signature
|
|
||||||
)
|
|
||||||
))
|
|
||||||
if result.status == "Accepted" or result.status == "AcceptedCanceled":
|
|
||||||
db_firmware_update.status = FirmwareUpdateStatus.SUBMITTED
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
return db_firmware_update, result.status
|
|
||||||
except TimeoutError as e:
|
|
||||||
raise e
|
|
||||||
|
|
||||||
async def update_firmware_status(chargepoint_identity: str, request_id: int, status: FirmwareUpdateStatus):
|
|
||||||
with SessionLocal() as db:
|
|
||||||
db_chargepoint = db.query(ChargePoint).filter(ChargePoint.identity == chargepoint_identity).first()
|
|
||||||
db_firmware_update = db.query(FirmwareUpdate).filter(FirmwareUpdate.chargepoint_id == db_chargepoint.id).filter(FirmwareUpdate.request_id == request_id).first()
|
|
||||||
db_firmware_update.status = FirmwareUpdateStatus(status)
|
|
||||||
db.commit()
|
|
|
@ -1,83 +0,0 @@
|
||||||
from datetime import datetime, UTC
|
|
||||||
import secrets
|
|
||||||
from uuid import UUID
|
|
||||||
from sqlalchemy import select, delete
|
|
||||||
from sqlalchemy.orm import Session as SqlaSession
|
|
||||||
|
|
||||||
from app.models.session import Session
|
|
||||||
from app.models.user import User
|
|
||||||
from app.util.errors import NotFoundError
|
|
||||||
|
|
||||||
|
|
||||||
async def get_sessions(
|
|
||||||
db: SqlaSession, skip: int = 0, limit: int = 20
|
|
||||||
) -> tuple[Session]:
|
|
||||||
stmt = select(Session).offset(skip).limit(limit)
|
|
||||||
result = db.execute(stmt)
|
|
||||||
return result.scalars().all()
|
|
||||||
|
|
||||||
|
|
||||||
async def get_sessions_by_user(db: SqlaSession, user_id: UUID) -> tuple[Session]:
|
|
||||||
stmt = select(Session).where(Session.user_id == user_id)
|
|
||||||
result = db.execute(stmt)
|
|
||||||
return result.scalars().all()
|
|
||||||
|
|
||||||
|
|
||||||
async def create_session(db: SqlaSession, user: User, useragent: str) -> Session:
|
|
||||||
session = Session(
|
|
||||||
name=useragent,
|
|
||||||
refresh_token=secrets.token_urlsafe(64),
|
|
||||||
last_used=datetime.now(UTC),
|
|
||||||
user_id=user.id,
|
|
||||||
)
|
|
||||||
db.add(session)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(session)
|
|
||||||
return session
|
|
||||||
|
|
||||||
|
|
||||||
async def remove_session(db: SqlaSession, id: UUID):
|
|
||||||
session = db.get(Session, id)
|
|
||||||
if not session:
|
|
||||||
raise NotFoundError
|
|
||||||
db.delete(session)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
async def remove_session_for_user(
|
|
||||||
db: SqlaSession, id: UUID, user_id: UUID
|
|
||||||
):
|
|
||||||
stmt = select(Session).where(Session.id == id and Session.user_id == user_id)
|
|
||||||
result = db.execute(stmt)
|
|
||||||
session = result.scalars().first()
|
|
||||||
if not session:
|
|
||||||
raise NotFoundError
|
|
||||||
db.delete(session)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
async def remove_all_sessions_for_user(db: SqlaSession, user_id: UUID):
|
|
||||||
stmt = delete(Session).where(Session.user_id == user_id)
|
|
||||||
db.execute(stmt)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
async def remove_all_sessions(db: SqlaSession):
|
|
||||||
stmt = delete(Session)
|
|
||||||
db.execute(stmt)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
async def validate_and_rotate_refresh_token(
|
|
||||||
db: SqlaSession, refresh_token: str
|
|
||||||
) -> Session:
|
|
||||||
stmt = select(Session).where(Session.refresh_token == refresh_token)
|
|
||||||
result = db.execute(stmt)
|
|
||||||
session = result.scalars().first()
|
|
||||||
if not session:
|
|
||||||
raise NotFoundError
|
|
||||||
session.refresh_token = secrets.token_urlsafe(64)
|
|
||||||
session.last_used = datetime.now(UTC)
|
|
||||||
|
|
||||||
db.commit()
|
|
||||||
return session
|
|
|
@ -1,69 +0,0 @@
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import secrets
|
|
||||||
from typing import Optional
|
|
||||||
from uuid import UUID
|
|
||||||
from jwcrypto import jwt, jwk
|
|
||||||
from datetime import datetime, timedelta, UTC
|
|
||||||
|
|
||||||
from app.models.user import User
|
|
||||||
from app.schemas.auth_token import AccessToken
|
|
||||||
from app.schemas.user import Role
|
|
||||||
from app.util.errors import InsufficientPermissionsError, InvalidTokenAudienceError
|
|
||||||
|
|
||||||
__signing_key = jwk.JWK.from_password(os.getenv("CS_TOKEN_SECRET", secrets.token_urlsafe(64)))
|
|
||||||
|
|
||||||
async def __create_token(claims: dict) -> str:
|
|
||||||
default_claims = {
|
|
||||||
"iss": os.getenv("CS_TOKEN_ISSUER", "https://localhost:8000"),
|
|
||||||
"iat": datetime.now(UTC).timestamp(),
|
|
||||||
}
|
|
||||||
header = {"alg": "HS256", "typ": "JWT", "kid": "default"}
|
|
||||||
token = jwt.JWT(header=header, claims=(claims | default_claims))
|
|
||||||
token.make_signed_token(__signing_key)
|
|
||||||
return token.serialize()
|
|
||||||
|
|
||||||
|
|
||||||
async def __verify_token(token: str, audience: str) -> dict | None:
|
|
||||||
try:
|
|
||||||
token = jwt.JWT(jwt=token, key=__signing_key)
|
|
||||||
claims = json.loads(token.claims)
|
|
||||||
if claims.get("aud") == audience:
|
|
||||||
return claims
|
|
||||||
else:
|
|
||||||
raise InvalidTokenAudienceError
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def create_access_token(
|
|
||||||
user: User, session_id: UUID
|
|
||||||
) -> tuple[str, datetime]:
|
|
||||||
token_lifetime = float(os.getenv("CS_ACCESS_TOKEN_LIFETIME_SECONDS", "300"))
|
|
||||||
exp_time = datetime.now(UTC) + timedelta(seconds=token_lifetime)
|
|
||||||
claims = {
|
|
||||||
"aud": "access",
|
|
||||||
"sub": str(user.id),
|
|
||||||
"exp": exp_time.timestamp(),
|
|
||||||
"session": str(session_id),
|
|
||||||
"role": str(user.role),
|
|
||||||
}
|
|
||||||
return await __create_token(claims=claims), exp_time
|
|
||||||
|
|
||||||
async def verify_access_token(
|
|
||||||
token: str, required_roles: Optional[list[str]] = None
|
|
||||||
) -> AccessToken | None:
|
|
||||||
try:
|
|
||||||
claims = await __verify_token(token=token, audience="access")
|
|
||||||
if not claims:
|
|
||||||
return None
|
|
||||||
if not required_roles or claims.get("role") in required_roles:
|
|
||||||
return AccessToken(
|
|
||||||
subject=claims.get("sub"),
|
|
||||||
role=Role(claims.get("role")),
|
|
||||||
session=claims.get("session"),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
raise InsufficientPermissionsError
|
|
||||||
except InvalidTokenAudienceError:
|
|
||||||
pass
|
|
|
@ -1,111 +0,0 @@
|
||||||
from uuid import UUID
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from argon2 import PasswordHasher
|
|
||||||
from argon2.exceptions import VerifyMismatchError
|
|
||||||
|
|
||||||
from app.models.user import User
|
|
||||||
from app.schemas.user import (
|
|
||||||
UserCreate,
|
|
||||||
UserUpdate,
|
|
||||||
AdministrativeUserUpdate,
|
|
||||||
PasswordUpdate,
|
|
||||||
LoginRequest,
|
|
||||||
)
|
|
||||||
from app.util.errors import InvalidStateError, NotFoundError
|
|
||||||
|
|
||||||
hasher = PasswordHasher(memory_cost=102400)
|
|
||||||
|
|
||||||
async def get_user(db: Session, id: UUID):
|
|
||||||
return db.get(User, id)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_user_by_email(db: Session, email: str):
|
|
||||||
stmt = select(User).where(User.email == email)
|
|
||||||
result = db.execute(stmt)
|
|
||||||
return result.scalars().first()
|
|
||||||
|
|
||||||
|
|
||||||
async def get_users(
|
|
||||||
db: Session, skip: int = 0, limit: int = 20, email: str = None
|
|
||||||
):
|
|
||||||
stmt = select(User)
|
|
||||||
if email is not None:
|
|
||||||
stmt = stmt.where(User.email.like(email))
|
|
||||||
stmt = stmt.offset(skip).limit(limit)
|
|
||||||
result = db.execute(stmt)
|
|
||||||
return result.scalars().all()
|
|
||||||
|
|
||||||
|
|
||||||
async def create_user(db: Session, user: UserCreate) -> User:
|
|
||||||
if await get_user_by_email(db=db, email=user.email):
|
|
||||||
raise InvalidStateError
|
|
||||||
hashed_password = hasher.hash(user.password)
|
|
||||||
db_user = User(
|
|
||||||
friendly_name=user.friendly_name, email=user.email, password=hashed_password
|
|
||||||
)
|
|
||||||
db.add(db_user)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(db_user)
|
|
||||||
return db_user
|
|
||||||
|
|
||||||
|
|
||||||
async def update_user(
|
|
||||||
db: Session,
|
|
||||||
id: UUID,
|
|
||||||
update: UserUpdate | AdministrativeUserUpdate,
|
|
||||||
) -> User:
|
|
||||||
db_user = await get_user(db, id)
|
|
||||||
if db_user is None:
|
|
||||||
raise NotFoundError
|
|
||||||
|
|
||||||
changed_attributes = dict()
|
|
||||||
for key, value in update.model_dump(exclude_unset=True).items():
|
|
||||||
changed_attributes[key] = {"old": getattr(db_user, key), "new": value}
|
|
||||||
setattr(db_user, key, value)
|
|
||||||
db.commit()
|
|
||||||
return db_user
|
|
||||||
|
|
||||||
|
|
||||||
async def change_user_password(db: Session, id: UUID, update: PasswordUpdate):
|
|
||||||
db_user = await get_user(db, id)
|
|
||||||
if db_user is None:
|
|
||||||
raise NotFoundError
|
|
||||||
try:
|
|
||||||
hasher.verify(hash=db_user.password, password=update.old_password)
|
|
||||||
db_user.password = hasher.hash(update.new_password)
|
|
||||||
db.commit()
|
|
||||||
except VerifyMismatchError:
|
|
||||||
raise InvalidStateError
|
|
||||||
|
|
||||||
|
|
||||||
async def remove_user(db: Session, id: UUID):
|
|
||||||
db_user = await get_user(db, id)
|
|
||||||
if db_user is None:
|
|
||||||
raise NotFoundError
|
|
||||||
db.delete(db_user)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
async def validate_login(db: Session, login: LoginRequest) -> User | None:
|
|
||||||
stmt = select(User).where(User.email == login.email)
|
|
||||||
result = db.execute(stmt)
|
|
||||||
db_user = result.scalars().first()
|
|
||||||
if db_user is None:
|
|
||||||
db.commit()
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
hasher.verify(hash=db_user.password, password=login.password)
|
|
||||||
if hasher.check_needs_rehash(db_user.password):
|
|
||||||
db_user.password = hasher.hash(login.password)
|
|
||||||
if db_user.is_active:
|
|
||||||
db.commit()
|
|
||||||
return db_user
|
|
||||||
else:
|
|
||||||
db.commit()
|
|
||||||
return None
|
|
||||||
except VerifyMismatchError:
|
|
||||||
db.commit()
|
|
||||||
return None
|
|
|
@ -1,14 +0,0 @@
|
||||||
class NotFoundError(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class InvalidStateError(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class InsufficientPermissionsError(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class InvalidTokenAudienceError(Exception):
|
|
||||||
pass
|
|
1772
backend/poetry.lock
generated
1772
backend/poetry.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -1,12 +0,0 @@
|
||||||
{
|
|
||||||
"folders": [
|
|
||||||
{
|
|
||||||
"name": "Backend",
|
|
||||||
"path": "../backend"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Root",
|
|
||||||
"path": "../"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
23
frontend/.gitignore
vendored
23
frontend/.gitignore
vendored
|
@ -1,23 +0,0 @@
|
||||||
node_modules
|
|
||||||
|
|
||||||
# Output
|
|
||||||
.output
|
|
||||||
.vercel
|
|
||||||
.netlify
|
|
||||||
.wrangler
|
|
||||||
/.svelte-kit
|
|
||||||
/build
|
|
||||||
|
|
||||||
# OS
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
|
|
||||||
# Env
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
!.env.example
|
|
||||||
!.env.test
|
|
||||||
|
|
||||||
# Vite
|
|
||||||
vite.config.js.timestamp-*
|
|
||||||
vite.config.ts.timestamp-*
|
|
|
@ -1 +0,0 @@
|
||||||
engine-strict=true
|
|
|
@ -1,4 +0,0 @@
|
||||||
# Package Managers
|
|
||||||
package-lock.json
|
|
||||||
pnpm-lock.yaml
|
|
||||||
yarn.lock
|
|
|
@ -1,11 +0,0 @@
|
||||||
{
|
|
||||||
"trailingComma": "es5",
|
|
||||||
"semi": false,
|
|
||||||
"singleQuote": true,
|
|
||||||
"useTabs": false,
|
|
||||||
"tabWidth": 2,
|
|
||||||
"quoteProps": "consistent",
|
|
||||||
"bracketSpacing": true,
|
|
||||||
"arrowParens": "always",
|
|
||||||
"printWidth": 100
|
|
||||||
}
|
|
|
@ -1,38 +0,0 @@
|
||||||
# sv
|
|
||||||
|
|
||||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
|
||||||
|
|
||||||
## Creating a project
|
|
||||||
|
|
||||||
If you're seeing this, you've probably already done this step. Congrats!
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# create a new project in the current directory
|
|
||||||
npx sv create
|
|
||||||
|
|
||||||
# create a new project in my-app
|
|
||||||
npx sv create my-app
|
|
||||||
```
|
|
||||||
|
|
||||||
## Developing
|
|
||||||
|
|
||||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run dev
|
|
||||||
|
|
||||||
# or start the server and open the app in a new browser tab
|
|
||||||
npm run dev -- --open
|
|
||||||
```
|
|
||||||
|
|
||||||
## Building
|
|
||||||
|
|
||||||
To create a production version of your app:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
You can preview the production build with `npm run preview`.
|
|
||||||
|
|
||||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
|
|
@ -1,39 +0,0 @@
|
||||||
import prettier from 'eslint-config-prettier'
|
|
||||||
import js from '@eslint/js'
|
|
||||||
import { includeIgnoreFile } from '@eslint/compat'
|
|
||||||
import svelte from 'eslint-plugin-svelte'
|
|
||||||
import globals from 'globals'
|
|
||||||
import { fileURLToPath } from 'node:url'
|
|
||||||
import ts from 'typescript-eslint'
|
|
||||||
import svelteConfig from './svelte.config.js'
|
|
||||||
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url))
|
|
||||||
|
|
||||||
export default ts.config(
|
|
||||||
includeIgnoreFile(gitignorePath),
|
|
||||||
js.configs.recommended,
|
|
||||||
...ts.configs.recommended,
|
|
||||||
...svelte.configs.recommended,
|
|
||||||
prettier,
|
|
||||||
...svelte.configs.prettier,
|
|
||||||
{
|
|
||||||
languageOptions: {
|
|
||||||
globals: {
|
|
||||||
...globals.browser,
|
|
||||||
...globals.node,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
|
|
||||||
ignores: ['eslint.config.js', 'svelte.config.js'],
|
|
||||||
|
|
||||||
languageOptions: {
|
|
||||||
parserOptions: {
|
|
||||||
projectService: true,
|
|
||||||
extraFileExtensions: ['.svelte'],
|
|
||||||
parser: ts.parser,
|
|
||||||
svelteConfig,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
3800
frontend/package-lock.json
generated
3800
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -1,40 +0,0 @@
|
||||||
{
|
|
||||||
"name": "frontend",
|
|
||||||
"private": true,
|
|
||||||
"version": "0.0.1",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite dev",
|
|
||||||
"build": "vite build",
|
|
||||||
"preview": "vite preview",
|
|
||||||
"prepare": "svelte-kit sync || echo ''",
|
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
|
||||||
"format": "prettier --write .",
|
|
||||||
"lint": "prettier --check . && eslint ."
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@eslint/compat": "^1.2.5",
|
|
||||||
"@eslint/js": "^9.18.0",
|
|
||||||
"@sveltejs/adapter-auto": "^4.0.0",
|
|
||||||
"@sveltejs/adapter-static": "^3.0.8",
|
|
||||||
"@sveltejs/kit": "^2.16.0",
|
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
|
||||||
"eslint": "^9.18.0",
|
|
||||||
"eslint-config-prettier": "^10.0.1",
|
|
||||||
"eslint-plugin-svelte": "^3.0.0",
|
|
||||||
"globals": "^16.0.0",
|
|
||||||
"prettier": "^3.4.2",
|
|
||||||
"prettier-plugin-svelte": "^3.3.3",
|
|
||||||
"svelte": "^5.0.0",
|
|
||||||
"svelte-check": "^4.0.0",
|
|
||||||
"typescript": "^5.0.0",
|
|
||||||
"typescript-eslint": "^8.20.0",
|
|
||||||
"vite": "^6.0.0"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@tailwindcss/vite": "^4.0.14",
|
|
||||||
"daisyui": "^5.0.3",
|
|
||||||
"tailwindcss": "^4.0.14"
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,17 +0,0 @@
|
||||||
{
|
|
||||||
"folders": [
|
|
||||||
{
|
|
||||||
"name": "Frontend",
|
|
||||||
"path": "../frontend",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Root",
|
|
||||||
"path": "../",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"settings": {
|
|
||||||
"editor.formatOnSave": true,
|
|
||||||
"editor.tabSize": 2,
|
|
||||||
"editor.detectIndentation": false,
|
|
||||||
},
|
|
||||||
}
|
|
13
frontend/src/app.d.ts
vendored
13
frontend/src/app.d.ts
vendored
|
@ -1,13 +0,0 @@
|
||||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
|
||||||
// for information about these interfaces
|
|
||||||
declare global {
|
|
||||||
namespace App {
|
|
||||||
// interface Error {}
|
|
||||||
// interface Locals {}
|
|
||||||
// interface PageData {}
|
|
||||||
// interface PageState {}
|
|
||||||
// interface Platform {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export {}
|
|
|
@ -1,12 +0,0 @@
|
||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
%sveltekit.head%
|
|
||||||
</head>
|
|
||||||
<body data-sveltekit-preload-data="hover">
|
|
||||||
<div style="display: contents">%sveltekit.body%</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
|
@ -1 +0,0 @@
|
||||||
// place files you want to import through the `$lib` alias in this folder.
|
|
|
@ -1 +0,0 @@
|
||||||
export const ssr = false
|
|
|
@ -1,6 +0,0 @@
|
||||||
<script>
|
|
||||||
import '../style.css'
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<h1>Welcome to SvelteKit</h1>
|
|
||||||
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
|
|
|
@ -1,6 +0,0 @@
|
||||||
@import 'tailwindcss';
|
|
||||||
@plugin "daisyui" {
|
|
||||||
themes:
|
|
||||||
light --default,
|
|
||||||
dark --prefersdark;
|
|
||||||
}
|
|
Binary file not shown.
Before Width: | Height: | Size: 1.5 KiB |
|
@ -1,17 +0,0 @@
|
||||||
import adapter from '@sveltejs/adapter-static'
|
|
||||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
|
|
||||||
|
|
||||||
/** @type {import('@sveltejs/kit').Config} */
|
|
||||||
const config = {
|
|
||||||
// Consult https://svelte.dev/docs/kit/integrations
|
|
||||||
// for more information about preprocessors
|
|
||||||
preprocess: vitePreprocess(),
|
|
||||||
|
|
||||||
kit: {
|
|
||||||
adapter: adapter({
|
|
||||||
fallback: 'index.html',
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export default config
|
|
|
@ -1,19 +0,0 @@
|
||||||
{
|
|
||||||
"extends": "./.svelte-kit/tsconfig.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"allowJs": true,
|
|
||||||
"checkJs": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"sourceMap": true,
|
|
||||||
"strict": true,
|
|
||||||
"moduleResolution": "bundler"
|
|
||||||
}
|
|
||||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
|
||||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
|
||||||
//
|
|
||||||
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
|
|
||||||
// from the referenced tsconfig.json - TypeScript does not merge them in
|
|
||||||
}
|
|
|
@ -1,10 +0,0 @@
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
|
||||||
import { sveltekit } from '@sveltejs/kit/vite'
|
|
||||||
import { defineConfig } from 'vite'
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [tailwindcss(), sveltekit()],
|
|
||||||
server: {
|
|
||||||
host: '127.0.0.1',
|
|
||||||
},
|
|
||||||
})
|
|
1180
poetry.lock
generated
Normal file
1180
poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
|
@ -1,23 +1,21 @@
|
||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "simple-ocpp-cs"
|
name = "simple-ocpp-cs"
|
||||||
version = "0.2.0"
|
version = "0.1.0"
|
||||||
description = "A simple OCPP 2.0.1 compliant central system"
|
description = "A simple OCPP 2.0.1 compliant central system"
|
||||||
authors = ["Bluemedia <hi@bluemedia.dev>"]
|
authors = ["Bluemedia <hi@bluemedia.dev>"]
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
package-mode = false
|
package-mode = false
|
||||||
|
|
||||||
[tool.poetry.dependencies]
|
[tool.poetry.dependencies]
|
||||||
python = "^3.12"
|
python = "^3.11"
|
||||||
ocpp = "^2.0.0"
|
ocpp = "^0.26.0"
|
||||||
fastapi = {extras = ["standard"], version = "^0.115.11"}
|
fastapi = "^0.110.0"
|
||||||
uvicorn = {extras = ["standard"], version = "^0.34.0"}
|
uvicorn = {extras = ["standard"], version = "^0.28.0"}
|
||||||
websockets = "^15.0"
|
websockets = "^12.0"
|
||||||
sqlalchemy = "^2.0.28"
|
sqlalchemy = "^2.0.28"
|
||||||
alembic = "^1.15.1"
|
alembic = "^1.13.1"
|
||||||
gunicorn = "^22.0.0"
|
gunicorn = "^22.0.0"
|
||||||
psycopg2-binary = "^2.9.10"
|
psycopg2-binary = "^2.9.9"
|
||||||
argon2-cffi = "^23.1.0"
|
|
||||||
jwcrypto = "^1.5.6"
|
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["poetry-core"]
|
requires = ["poetry-core"]
|
Loading…
Add table
Reference in a new issue