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:
|
||||
docker:
|
||||
image: woodpeckerci/plugin-docker-buildx
|
||||
secrets: [docker_username, docker_password]
|
||||
settings:
|
||||
pull_image: true
|
||||
registry: git.bluemedia.dev
|
||||
repo: git.bluemedia.dev/bluemedia/simple-ocpp-cs
|
||||
username:
|
||||
from_secret: docker_username
|
||||
password:
|
||||
from_secret: docker_password
|
||||
platforms: linux/amd64
|
||||
tag: latest
|
16
Dockerfile
16
Dockerfile
|
@ -1,5 +1,5 @@
|
|||
# `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
|
||||
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
|
||||
FROM python-base AS builder-base
|
||||
FROM python-base as builder-base
|
||||
RUN apt-get update \
|
||||
&& apt-get install --no-install-recommends -y \
|
||||
# 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.
|
||||
WORKDIR $PYSETUP_PATH
|
||||
COPY backend/poetry.lock backend/pyproject.toml ./
|
||||
COPY poetry.lock pyproject.toml ./
|
||||
|
||||
# install runtime deps - uses $POETRY_VIRTUALENVS_IN_PROJECT internally
|
||||
RUN poetry install
|
||||
RUN poetry install --no-dev
|
||||
|
||||
# `production` image used for runtime
|
||||
FROM python-base AS production
|
||||
FROM python-base as production
|
||||
ENV FASTAPI_ENV=production
|
||||
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 backend/alembic /usr/src/alembic
|
||||
COPY backend/app /usr/src/app
|
||||
COPY ./alembic /usr/src/alembic
|
||||
COPY ./app /usr/src/app
|
||||
WORKDIR /usr/src
|
||||
CMD ["bash", "start.sh"]
|
|
@ -1,12 +1,9 @@
|
|||
from logging.config import fileConfig
|
||||
import os
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from sqlalchemy import create_engine
|
||||
from alembic import context
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# Import models for autogenerate support
|
||||
from app.database import Base
|
||||
from app.models import *
|
|
@ -3,10 +3,7 @@ from sqlalchemy import create_engine
|
|||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
if os.getenv("CS_DATABASE_URL", "invalid") == "invalid":
|
||||
raise SystemExit('ERROR: Invalid CS_DATABASE_URL environment variable')
|
||||
|
||||
SQLALCHEMY_DATABASE_URL = os.getenv("CS_DATABASE_URL")
|
||||
SQLALCHEMY_DATABASE_URL = os.getenv("CS_DATABASE_URL", "sqlite:///./simple-ocpp-cs.db")
|
||||
|
||||
if SQLALCHEMY_DATABASE_URL.startswith("sqlite"):
|
||||
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",
|
||||
"connector",
|
||||
"firmware_update",
|
||||
"id_token",
|
||||
"meter_value",
|
||||
"session",
|
||||
"transaction",
|
||||
"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.v201 import ChargePoint as cp
|
||||
from ocpp.v201 import call_result
|
||||
from ocpp.v201.enums import Action, RegistrationStatusEnumType, TransactionEventEnumType
|
||||
from ocpp.v201.call import GetBaseReport
|
||||
from ocpp.v201.enums import Action, RegistrationStatusType, TransactionEventType
|
||||
from ocpp.v201.call import GetBaseReportPayload
|
||||
|
||||
from app.services import (
|
||||
firmware_service,
|
||||
variable_service,
|
||||
id_token_service,
|
||||
chargepoint_service,
|
||||
|
@ -17,42 +16,42 @@ from app.services import (
|
|||
|
||||
class ChargePoint(cp):
|
||||
|
||||
@on(Action.boot_notification)
|
||||
@on(Action.BootNotification)
|
||||
async def on_boot_notification(self, charging_station, **kwargs):
|
||||
await chargepoint_service.update_attributes(
|
||||
chargepoint_identity=self.id,
|
||||
charging_station=charging_station
|
||||
)
|
||||
return call_result.BootNotification(
|
||||
return call_result.BootNotificationPayload(
|
||||
current_time=datetime.now(UTC).isoformat(),
|
||||
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):
|
||||
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):
|
||||
for entry in report_data:
|
||||
await variable_service.create_or_update_variable(
|
||||
chargepoint_identity=self.id,
|
||||
report_entry=entry
|
||||
)
|
||||
return call_result.NotifyReport()
|
||||
return call_result.NotifyReportPayload()
|
||||
|
||||
@on(Action.heartbeat)
|
||||
@on(Action.Heartbeat)
|
||||
async def on_heartbeat_request(self):
|
||||
return call_result.Heartbeat(
|
||||
return call_result.HeartbeatPayload(
|
||||
current_time=datetime.now(UTC).isoformat()
|
||||
)
|
||||
|
||||
@after(Action.heartbeat)
|
||||
@after(Action.Heartbeat)
|
||||
async def after_heartbeat_request(self):
|
||||
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):
|
||||
await chargepoint_service.create_or_update_connector(
|
||||
chargepoint_identity=self.id,
|
||||
|
@ -60,14 +59,14 @@ class ChargePoint(cp):
|
|||
connector_id=connector_id,
|
||||
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):
|
||||
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(
|
||||
self,
|
||||
event_type,
|
||||
|
@ -83,7 +82,7 @@ class ChargePoint(cp):
|
|||
id_token_info = None
|
||||
token_owner_id = None
|
||||
|
||||
if event_type == str(TransactionEventEnumType.started):
|
||||
if event_type == str(TransactionEventType.started):
|
||||
await transaction_service.create_transaction(
|
||||
chargepoint_identity=self.id,
|
||||
user_id=token_owner_id,
|
||||
|
@ -91,12 +90,12 @@ class ChargePoint(cp):
|
|||
transaction_info=transaction_info,
|
||||
transaction_data=kwargs
|
||||
)
|
||||
elif event_type == str(TransactionEventEnumType.updated):
|
||||
elif event_type == str(TransactionEventType.updated):
|
||||
await transaction_service.update_transaction(
|
||||
transaction_id=transaction_info["transaction_id"],
|
||||
transaction_data=kwargs
|
||||
)
|
||||
elif event_type == str(TransactionEventEnumType.ended):
|
||||
elif event_type == str(TransactionEventType.ended):
|
||||
await transaction_service.end_transaction(
|
||||
transaction_id=transaction_info["transaction_id"],
|
||||
timestamp=datetime.fromisoformat(timestamp),
|
||||
|
@ -106,19 +105,14 @@ class ChargePoint(cp):
|
|||
)
|
||||
|
||||
if id_token_info == None:
|
||||
return call_result.TransactionEvent()
|
||||
return call_result.TransactionEventPayload()
|
||||
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)
|
||||
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)
|
||||
@on(Action.MeterValues)
|
||||
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):
|
||||
return call_result.SecurityEventNotification()
|
||||
return call_result.SecurityEventNotificationPayload()
|
|
@ -3,7 +3,6 @@ from typing import Any, Coroutine, Dict
|
|||
from uuid import UUID
|
||||
|
||||
from websockets import ConnectionClosed
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
from app.ocpp_proto.chargepoint import ChargePoint
|
||||
|
||||
|
@ -13,7 +12,7 @@ async def start(id: UUID, cp: ChargePoint):
|
|||
try:
|
||||
__active_connections[id] = cp
|
||||
await cp.start()
|
||||
except (ConnectionClosed, WebSocketDisconnect):
|
||||
except ConnectionClosed:
|
||||
logging.info("Charging station '%s' (%s) disconnected", cp.id, id)
|
||||
__active_connections.pop(id, None)
|
||||
|
|
@ -2,15 +2,14 @@ import random
|
|||
import string
|
||||
from datetime import datetime, timedelta, UTC
|
||||
from uuid import UUID
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, HTTPException, Security
|
||||
from fastapi.params import Depends
|
||||
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.ocpp_proto import chargepoint_manager
|
||||
from app.schemas.auth_token import AccessToken
|
||||
from app.schemas.chargepoint import (
|
||||
ChargePoint,
|
||||
ChargePointCreate,
|
||||
|
@ -28,13 +27,10 @@ from app.schemas.chargepoint_variable import (
|
|||
MutabilityType,
|
||||
SetVariableStatusType
|
||||
)
|
||||
from app.schemas.firmware_update import FirmwareUpdate, FirmwareUpdateCreate, FirmwareUpdateSubmissionResponse
|
||||
from app.models.chargepoint import ChargePoint as DbChargePoint
|
||||
from app.models.user import User as DbUser
|
||||
from app.models.chargepoint_variable import ChargepointVariable as DbChargepointVariable
|
||||
from app.models.firmware_update import FirmwareUpdate as DbFirmwareUpdate
|
||||
from app.security.jwt_bearer import JWTBearer
|
||||
from app.services import firmware_service
|
||||
from app.security import get_api_key
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/chargepoints",
|
||||
|
@ -45,16 +41,16 @@ router = APIRouter(
|
|||
async def get_chargepoints(
|
||||
skip: int = 0,
|
||||
limit: int = 20,
|
||||
db: Session = Depends(get_db),
|
||||
token: AccessToken = Depends(JWTBearer()),
|
||||
api_key: str = Security(get_api_key),
|
||||
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)
|
||||
async def get_chargepoint(
|
||||
chargepoint_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
token: AccessToken = Depends(JWTBearer()),
|
||||
api_key: str = Security(get_api_key),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
chargepoint = db.get(DbChargePoint, chargepoint_id)
|
||||
if chargepoint is None:
|
||||
|
@ -64,8 +60,8 @@ async def get_chargepoint(
|
|||
@router.get(path="/{chargepoint_id}/password", response_model=ChargePointPassword)
|
||||
async def get_chargepoint_password(
|
||||
chargepoint_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
|
||||
api_key: str = Security(get_api_key),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
chargepoint = db.get(DbChargePoint, chargepoint_id)
|
||||
if chargepoint is None:
|
||||
|
@ -75,8 +71,8 @@ async def get_chargepoint_password(
|
|||
@router.delete(path="/{chargepoint_id}/password", response_model=ChargePointPassword)
|
||||
async def reset_chargepoint_password(
|
||||
chargepoint_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
|
||||
api_key: str = Security(get_api_key),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
chargepoint = db.get(DbChargePoint, chargepoint_id)
|
||||
if chargepoint is None:
|
||||
|
@ -88,8 +84,8 @@ async def reset_chargepoint_password(
|
|||
@router.post(path="", status_code=201, response_model=ChargePoint)
|
||||
async def create_chargepoint(
|
||||
chargepoint: ChargePointCreate,
|
||||
db: Session = Depends(get_db),
|
||||
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
|
||||
api_key: str = Security(get_api_key),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
chargepoint_db = DbChargePoint(
|
||||
identity=chargepoint.identity,
|
||||
|
@ -106,8 +102,8 @@ async def create_chargepoint(
|
|||
async def update_chargepoint(
|
||||
chargepoint_id: UUID,
|
||||
chargepoint_update: ChargePointUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
|
||||
api_key: str = Security(get_api_key),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
chargepoint = db.get(DbChargePoint, chargepoint_id)
|
||||
if chargepoint is None:
|
||||
|
@ -120,8 +116,8 @@ async def update_chargepoint(
|
|||
@router.delete(path="/{chargepoint_id}", response_model=None)
|
||||
async def delete_chargepoint(
|
||||
chargepoint_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
|
||||
api_key: str = Security(get_api_key),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
chargepoint = db.get(DbChargePoint, chargepoint_id)
|
||||
if chargepoint is None:
|
||||
|
@ -133,7 +129,7 @@ async def delete_chargepoint(
|
|||
@router.get(path="/{chargepoint_id}/status", response_model=ChargePointConnectionInfo)
|
||||
async def get_chargepoint_status(
|
||||
chargepoint_id: UUID,
|
||||
token: AccessToken = Depends(JWTBearer()),
|
||||
api_key: str = Security(get_api_key)
|
||||
):
|
||||
return ChargePointConnectionInfo(
|
||||
connected=chargepoint_manager.is_connected(chargepoint_id)
|
||||
|
@ -143,14 +139,14 @@ async def get_chargepoint_status(
|
|||
async def reset_chargepoint(
|
||||
chargepoint_id: UUID,
|
||||
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:
|
||||
raise HTTPException(status_code=503, detail="Chargepoint not connected.")
|
||||
try:
|
||||
response = await chargepoint_manager.call(
|
||||
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)
|
||||
except TimeoutError:
|
||||
|
@ -160,8 +156,8 @@ async def reset_chargepoint(
|
|||
async def create_id_token_learn_request(
|
||||
chargepoint_id: UUID,
|
||||
learn_request: IdTokenLearnRequest,
|
||||
db: Session = Depends(get_db),
|
||||
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
|
||||
api_key: str = Security(get_api_key),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
chargepoint = db.get(DbChargePoint, chargepoint_id)
|
||||
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)
|
||||
async def get_id_token_learn_request(
|
||||
chargepoint_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
|
||||
api_key: str = Security(get_api_key),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
chargepoint = db.get(DbChargePoint, chargepoint_id)
|
||||
if chargepoint is None:
|
||||
|
@ -210,8 +206,8 @@ async def get_id_token_learn_request(
|
|||
@router.delete(path="/{chargepoint_id}/token-learning", response_model=[])
|
||||
async def get_id_token_learn_request(
|
||||
chargepoint_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
|
||||
api_key: str = Security(get_api_key),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
chargepoint = db.get(DbChargePoint, chargepoint_id)
|
||||
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])
|
||||
async def get_chargepoint_variables(
|
||||
chargepoint_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
|
||||
api_key: str = Security(get_api_key),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
chargepoint = db.get(DbChargePoint, chargepoint_id)
|
||||
if chargepoint is None:
|
||||
|
@ -243,8 +239,8 @@ async def update_chargepoint_variable(
|
|||
chargepoint_id: UUID,
|
||||
variable_id: UUID,
|
||||
variable_update: ChargepointVariableUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
|
||||
api_key: str = Security(get_api_key),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
chargepoint = db.get(DbChargePoint, chargepoint_id)
|
||||
if chargepoint is None:
|
||||
|
@ -273,7 +269,7 @@ async def update_chargepoint_variable(
|
|||
evse['connectorId'] = variable.connector_id
|
||||
result = await chargepoint_manager.call(
|
||||
chargepoint_id,
|
||||
payload=SetVariables(set_variable_data=[
|
||||
payload=SetVariablesPayload(set_variable_data=[
|
||||
{
|
||||
'attributeType': variable.type.value,
|
||||
'attributeValue': variable_update.value,
|
||||
|
@ -296,67 +292,3 @@ async def update_chargepoint_variable(
|
|||
return ChargepointVariableResponse(status=status)
|
||||
except TimeoutError:
|
||||
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 fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, HTTPException, Security
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.params import Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.schemas.auth_token import AccessToken
|
||||
from app.schemas.id_token import IdToken, IdTokenCreate, IdTokenUpdate
|
||||
from app.models.id_token import IdToken as DbIdToken
|
||||
from app.models.user import User as DbUser
|
||||
from app.schemas.user import Role
|
||||
from app.security.jwt_bearer import JWTBearer
|
||||
from app.security import get_api_key
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/id-tokens",
|
||||
|
@ -18,43 +16,32 @@ router = APIRouter(
|
|||
)
|
||||
|
||||
@router.get(path="", response_model=list[IdToken])
|
||||
async def get_id_tokens(
|
||||
async def get_it_tokens(
|
||||
skip: int = 0,
|
||||
limit: int = 20,
|
||||
db: Session = Depends(get_db),
|
||||
token: AccessToken = Depends(JWTBearer()),
|
||||
api_key: str = Security(get_api_key),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
stmt = select(DbIdToken)
|
||||
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()
|
||||
return db.query(DbIdToken).offset(skip).limit(limit).all()
|
||||
|
||||
@router.get(path="/{id_token_id}", response_model=IdToken)
|
||||
async def get_id_token(
|
||||
id_token_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
token: AccessToken = Depends(JWTBearer()),
|
||||
api_key: str = Security(get_api_key),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
stmt = select(DbIdToken).where(DbIdToken.id == id_token_id)
|
||||
result = db.execute(stmt)
|
||||
id_token = result.scalars().first()
|
||||
id_token = db.get(DbIdToken, id_token_id)
|
||||
if id_token == None:
|
||||
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
|
||||
|
||||
@router.post(path="", status_code=201, response_model=IdToken)
|
||||
async def create_id_token(
|
||||
create_id_token: IdTokenCreate,
|
||||
db: Session = Depends(get_db),
|
||||
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
|
||||
api_key: str = Security(get_api_key),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
stmt = select(DbUser).where(DbUser.id == create_id_token.owner_id)
|
||||
result = db.execute(stmt)
|
||||
owner = result.scalars().first()
|
||||
owner = db.get(DbUser, create_id_token.owner_id)
|
||||
if owner == None:
|
||||
raise HTTPException(status_code=422, detail=[{
|
||||
"loc": ["body", "owner_id"],
|
||||
|
@ -76,19 +63,15 @@ async def create_id_token(
|
|||
async def update_id_token(
|
||||
id_token_id: UUID,
|
||||
id_token_update: IdTokenUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
|
||||
api_key: str = Security(get_api_key),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
stmt = select(DbIdToken).where(DbIdToken.id == id_token_id)
|
||||
result = db.execute(stmt)
|
||||
id_token = result.scalars().first()
|
||||
id_token = db.get(DbIdToken, id_token_id)
|
||||
if id_token is None:
|
||||
raise HTTPException(status_code=404, detail="IdToken not found")
|
||||
for key, value in id_token_update.model_dump(exclude_unset=True).items():
|
||||
if key == "owner_id":
|
||||
stmt = select(DbUser).where(DbUser.id == id_token_update.owner_id)
|
||||
result = db.execute(stmt)
|
||||
owner = result.scalars().first()
|
||||
owner = db.get(DbUser, value)
|
||||
if owner == None:
|
||||
raise HTTPException(status_code=422, detail=[{
|
||||
"loc": ["body", "owner_id"],
|
||||
|
@ -102,12 +85,10 @@ async def update_id_token(
|
|||
@router.delete(path="/{id_token_id}", response_model=None)
|
||||
async def delete_id_token(
|
||||
id_token_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
|
||||
api_key: str = Security(get_api_key),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
stmt = select(DbIdToken).where(DbIdToken.id == id_token_id)
|
||||
result = db.execute(stmt)
|
||||
id_token = result.scalars().first()
|
||||
id_token = db.get(DbIdToken, id_token_id)
|
||||
if id_token == None:
|
||||
raise HTTPException(status_code=404, detail="IdToken not found")
|
||||
db.delete(id_token)
|
|
@ -1,12 +1,11 @@
|
|||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from fastapi.params import Security
|
||||
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.schemas.meter_value import MeterValue
|
||||
from app.models.meter_value import MeterValue as DbMeterValue
|
||||
from app.security.jwt_bearer import JWTBearer
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/meter-values",
|
||||
|
@ -17,9 +16,7 @@ router = APIRouter(
|
|||
async def get_meter_values(
|
||||
skip: int = 0,
|
||||
limit: int = 20,
|
||||
db: Session = Depends(get_db),
|
||||
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
|
||||
api_key: str = Security(get_api_key),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
stmt = select(DbMeterValue).order_by(DbMeterValue.timestamp).offset(skip).limit(limit)
|
||||
result = db.execute(stmt)
|
||||
return result.scalars().all()
|
||||
return db.query(DbMeterValue).offset(skip).limit(limit).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 ocpp.v201.enums import ResetEnumType, ResetStatusEnumType
|
||||
from ocpp.v201.enums import ResetType, ResetStatusType
|
||||
|
||||
class ChargePointBase(BaseModel):
|
||||
identity: str
|
||||
|
@ -40,8 +40,8 @@ class ChargePointConnectionInfo(BaseModel):
|
|||
connected: bool
|
||||
|
||||
class ChargePointResetRequest(BaseModel):
|
||||
type: ResetEnumType
|
||||
type: ResetType
|
||||
evse_id: Optional[int] = None
|
||||
|
||||
class ChargePointResetResponse(BaseModel):
|
||||
status: ResetStatusEnumType
|
||||
status: ResetStatusType
|
|
@ -3,6 +3,8 @@ from typing import Optional
|
|||
from uuid import UUID
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.schemas.user import User
|
||||
|
||||
class IdTokenBase(BaseModel):
|
||||
friendly_name: str
|
||||
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 ocpp.v201.datatypes import IdTokenInfoType
|
||||
from ocpp.v201.enums import AuthorizationStatusEnumType
|
||||
from ocpp.v201.enums import AuthorizationStatusType
|
||||
|
||||
from app.database import SessionLocal
|
||||
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
|
||||
if id_token["type"] not in ["ISO14443", "ISO15693"]:
|
||||
return IdTokenInfoType(
|
||||
status=AuthorizationStatusEnumType.invalid
|
||||
status=AuthorizationStatusType.invalid
|
||||
), owner_id
|
||||
|
||||
with SessionLocal() as db:
|
||||
db_id_token = db.query(IdToken).filter(IdToken.token == id_token["id_token"]).first()
|
||||
if db_id_token == None:
|
||||
id_token_info = IdTokenInfoType(
|
||||
status=AuthorizationStatusEnumType.unknown
|
||||
status=AuthorizationStatusType.unknown
|
||||
)
|
||||
db_chargepoint = db.query(ChargePoint).filter(ChargePoint.identity == chargepoint_id).first()
|
||||
# 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)
|
||||
|
||||
id_token_info=IdTokenInfoType(
|
||||
status=AuthorizationStatusEnumType.accepted
|
||||
status=AuthorizationStatusType.accepted
|
||||
)
|
||||
owner_id = db_id_token.owner_id
|
||||
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
|
||||
if db_id_token.is_active == False:
|
||||
id_token_info=IdTokenInfoType(
|
||||
status=AuthorizationStatusEnumType.blocked
|
||||
status=AuthorizationStatusType.blocked
|
||||
)
|
||||
else:
|
||||
id_token_info=IdTokenInfoType(
|
||||
status=AuthorizationStatusEnumType.accepted
|
||||
status=AuthorizationStatusType.accepted
|
||||
)
|
||||
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]
|
||||
name = "simple-ocpp-cs"
|
||||
version = "0.2.0"
|
||||
version = "0.1.0"
|
||||
description = "A simple OCPP 2.0.1 compliant central system"
|
||||
authors = ["Bluemedia <hi@bluemedia.dev>"]
|
||||
readme = "README.md"
|
||||
package-mode = false
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.12"
|
||||
ocpp = "^2.0.0"
|
||||
fastapi = {extras = ["standard"], version = "^0.115.11"}
|
||||
uvicorn = {extras = ["standard"], version = "^0.34.0"}
|
||||
websockets = "^15.0"
|
||||
python = "^3.11"
|
||||
ocpp = "^0.26.0"
|
||||
fastapi = "^0.110.0"
|
||||
uvicorn = {extras = ["standard"], version = "^0.28.0"}
|
||||
websockets = "^12.0"
|
||||
sqlalchemy = "^2.0.28"
|
||||
alembic = "^1.15.1"
|
||||
alembic = "^1.13.1"
|
||||
gunicorn = "^22.0.0"
|
||||
psycopg2-binary = "^2.9.10"
|
||||
argon2-cffi = "^23.1.0"
|
||||
jwcrypto = "^1.5.6"
|
||||
psycopg2-binary = "^2.9.9"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core"]
|
Loading…
Add table
Reference in a new issue