Prepare monorepo
This commit is contained in:
parent
a1ddb43ed0
commit
938582155d
61 changed files with 5 additions and 5 deletions
24
backend/.devcontainer/devcontainer.json
Normal file
24
backend/.devcontainer/devcontainer.json
Normal file
|
@ -0,0 +1,24 @@
|
|||
// 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": "/workspace",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
19
backend/.devcontainer/docker-compose.yml
Normal file
19
backend/.devcontainer/docker-compose.yml
Normal file
|
@ -0,0 +1,19 @@
|
|||
services:
|
||||
app:
|
||||
image: mcr.microsoft.com/devcontainers/python:3.12-bookworm
|
||||
volumes:
|
||||
- ..:/workspace: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:
|
3
backend/.gitignore
vendored
Normal file
3
backend/.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
**/__pycache__
|
||||
simple-ocpp-cs.db
|
||||
.env
|
113
backend/alembic.ini
Normal file
113
backend/alembic.ini
Normal file
|
@ -0,0 +1,113 @@
|
|||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts
|
||||
script_location = alembic
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||
# for all available tokens
|
||||
file_template = %%(year)d%%(month).2d%%(day).2d_%%(rev)s-%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory.
|
||||
prepend_sys_path = .
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the python>=3.9 or backports.zoneinfo library.
|
||||
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
|
||||
# string value is passed to ZoneInfo()
|
||||
# leave blank for localtime
|
||||
timezone = UTC
|
||||
|
||||
# max length of characters to apply to the
|
||||
# "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to alembic/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "version_path_separator" below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
|
||||
|
||||
# version path separator; As mentioned above, this is the character used to split
|
||||
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
|
||||
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
|
||||
# Valid values for version_path_separator are:
|
||||
#
|
||||
# version_path_separator = :
|
||||
# version_path_separator = ;
|
||||
# version_path_separator = space
|
||||
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
output_encoding = utf-8
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
|
||||
# hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = %(here)s/.venv/bin/ruff
|
||||
# ruff.options = --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
62
backend/alembic/env.py
Normal file
62
backend/alembic/env.py
Normal file
|
@ -0,0 +1,62 @@
|
|||
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 *
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
# from myapp import mymodel
|
||||
# target_metadata = mymodel.Base.metadata
|
||||
target_metadata = Base.metadata
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
SQLALCHEMY_DATABASE_URL = os.getenv("CS_DATABASE_URL", "sqlite:///./simple-ocpp-cs.db")
|
||||
|
||||
if SQLALCHEMY_DATABASE_URL.startswith("sqlite"):
|
||||
connectable = create_engine(
|
||||
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
|
||||
)
|
||||
else:
|
||||
connectable = create_engine(SQLALCHEMY_DATABASE_URL)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection, target_metadata=target_metadata
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
raise NotImplementedError("Offline migration is not supported")
|
||||
else:
|
||||
run_migrations_online()
|
26
backend/alembic/script.py.mako
Normal file
26
backend/alembic/script.py.mako
Normal file
|
@ -0,0 +1,26 @@
|
|||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
|
@ -0,0 +1,146 @@
|
|||
"""Initial migration
|
||||
|
||||
Revision ID: 097d427dfa07
|
||||
Revises:
|
||||
Create Date: 2024-04-20 11:30:32.425878+00:00
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '097d427dfa07'
|
||||
down_revision: Union[str, None] = None
|
||||
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('users',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('friendly_name', sa.String(), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_users_friendly_name'), 'users', ['friendly_name'], unique=True)
|
||||
op.create_table('chargepoints',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('identity', sa.String(), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=True),
|
||||
sa.Column('password', sa.String(), nullable=True),
|
||||
sa.Column('price', sa.Numeric(precision=10, scale=2), nullable=True),
|
||||
sa.Column('last_seen', sa.DateTime(), nullable=True),
|
||||
sa.Column('vendor_name', sa.String(), nullable=True),
|
||||
sa.Column('model', sa.String(), nullable=True),
|
||||
sa.Column('serial_number', sa.String(), nullable=True),
|
||||
sa.Column('firmware_version', sa.String(), nullable=True),
|
||||
sa.Column('learn_user_id', sa.Uuid(), nullable=True),
|
||||
sa.Column('learn_until', sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['learn_user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_chargepoints_identity'), 'chargepoints', ['identity'], unique=True)
|
||||
op.create_table('id_tokens',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('friendly_name', sa.String(), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=True),
|
||||
sa.Column('token', sa.String(), nullable=True),
|
||||
sa.Column('owner_id', sa.Uuid(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_id_tokens_token'), 'id_tokens', ['token'], unique=False)
|
||||
op.create_table('chargepoint_variables',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=True),
|
||||
sa.Column('type', sa.Enum('ACTUAL', 'TARGET', 'MIN_SET', 'MAX_SET', name='attributetype'), nullable=True),
|
||||
sa.Column('value', sa.String(), nullable=True),
|
||||
sa.Column('mutability', sa.Enum('READ_ONLY', 'WRITE_ONLY', 'READ_WRITE', name='mutabilitytype'), nullable=True),
|
||||
sa.Column('persistent', sa.Boolean(), nullable=True),
|
||||
sa.Column('constant', sa.Boolean(), nullable=True),
|
||||
sa.Column('unit', sa.String(), nullable=True),
|
||||
sa.Column('data_type', sa.Enum('STRING', 'DECIMAL', 'INTEGER', 'DATETIME', 'BOOLEAN', 'OPTION_LIST', 'SEQUENCE_LIST', 'MEMBER_LIST', name='datatype'), nullable=True),
|
||||
sa.Column('min_limit', sa.Numeric(), nullable=True),
|
||||
sa.Column('max_limit', sa.Numeric(), nullable=True),
|
||||
sa.Column('values_list', sa.String(), nullable=True),
|
||||
sa.Column('component_name', sa.String(), nullable=True),
|
||||
sa.Column('component_instance', sa.String(), nullable=True),
|
||||
sa.Column('evse', sa.Integer(), nullable=True),
|
||||
sa.Column('connector_id', sa.Integer(), 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_chargepoint_variables_chargepoint_id'), 'chargepoint_variables', ['chargepoint_id'], unique=False)
|
||||
op.create_table('connectors',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('evse', sa.Integer(), nullable=True),
|
||||
sa.Column('index', sa.Integer(), nullable=True),
|
||||
sa.Column('status', sa.Enum('AVAILABLE', 'OCCUPIED', 'RESERVED', 'UNAVAILABLE', 'FAULTED', name='connectorstatus'), nullable=True),
|
||||
sa.Column('chargepoint_id', sa.Uuid(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['chargepoint_id'], ['chargepoints.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('transactions',
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('status', sa.Enum('ONGOING', 'ENDED', name='transactionstatus'), nullable=True),
|
||||
sa.Column('started_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('ended_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('meter_start', sa.Numeric(precision=10, scale=2), nullable=True),
|
||||
sa.Column('meter_end', sa.Numeric(precision=10, scale=2), nullable=True),
|
||||
sa.Column('end_reason', sa.Enum('AUTHORIZED', 'CABLE_PLUGGED_IN', 'CHARGING_RATE_CHANGED', 'CHARGING_STATE_CHANGED', 'DEAUTHORIZED', 'ENERGY_LIMIT_REACHED', 'EV_COMMUNICATION_LOST', 'EV_CONNECT_TIMEOUT', 'METER_VALUE_CLOCK', 'METER_VALUE_PERIODIC', 'TIME_LIMIT_REACHED', 'TRIGGER', 'UNLOCK_COMMAND', 'STOP_AUTHORIZED', 'EV_DEPARTED', 'EV_DETECTED', 'REMOTE_STOP', 'REMOTE_START', 'ABNORMAL_CONDITION', 'SIGNED_DATA_RECEIVED', 'RESET_COMMAND', name='transactioneventtriggerreason'), nullable=True),
|
||||
sa.Column('price', sa.Numeric(precision=10, scale=2), nullable=True),
|
||||
sa.Column('user_id', sa.Uuid(), nullable=True),
|
||||
sa.Column('chargepoint_id', sa.Uuid(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['chargepoint_id'], ['chargepoints.id'], ),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_transactions_chargepoint_id'), 'transactions', ['chargepoint_id'], unique=False)
|
||||
op.create_index(op.f('ix_transactions_ended_at'), 'transactions', ['ended_at'], unique=False)
|
||||
op.create_index(op.f('ix_transactions_started_at'), 'transactions', ['started_at'], unique=False)
|
||||
op.create_index(op.f('ix_transactions_status'), 'transactions', ['status'], unique=False)
|
||||
op.create_index(op.f('ix_transactions_user_id'), 'transactions', ['user_id'], unique=False)
|
||||
op.create_table('meter_values',
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('timestamp', sa.DateTime(), nullable=True),
|
||||
sa.Column('measurand', sa.Enum('CURRENT_EXPORT', 'CURRENT_IMPORT', 'CURRENT_OFFERED', 'ENERGY_ACTIVE_NET', 'ENERGY_ACTIVE_EXPORT_REGISTER', 'ENERGY_ACTIVE_IMPORT_REGISTER', 'ENERGY_ACTIVE_EXPORT_INTERVAL', 'ENERGY_ACTIVE_IMPORT_INTERVAL', 'ENERGY_REACTIVE_NET', 'ENERGY_REACTIVE_EXPORT_REGISTER', 'ENERGY_REACTIVE_IMPORT_REGISTER', 'ENERGY_REACTIVE_EXPORT_INTERVAL', 'ENERGY_REACTIVE_IMPORT_INTERVAL', 'ENERGY_APPARENT_NET', 'ENERGY_APPARENT_IMPORT', 'ENERGY_APPARENT_EXPORT', 'FREQUENCY', 'POWER_ACTIVE_EXPORT', 'POWER_ACTIVE_IMPORT', 'POWER_FACTOR', 'POWER_OFFERED', 'POWER_REACTIVE_EXPORT', 'POWER_REACTIVE_IMPORT', 'SOC', 'VOLTAGE', name='measurand'), nullable=True),
|
||||
sa.Column('phase_type', sa.Enum('L1', 'L2', 'L3', 'N', 'L1_N', 'L2_N', 'L3_N', 'L1_L2', 'L2_L3', 'L3_L1', name='phasetype'), nullable=True),
|
||||
sa.Column('unit', sa.String(), nullable=True),
|
||||
sa.Column('value', sa.Float(), nullable=True),
|
||||
sa.Column('transaction_id', sa.String(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['transaction_id'], ['transactions.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_meter_values_measurand'), 'meter_values', ['measurand'], unique=False)
|
||||
op.create_index(op.f('ix_meter_values_timestamp'), 'meter_values', ['timestamp'], unique=False)
|
||||
op.create_index(op.f('ix_meter_values_transaction_id'), 'meter_values', ['transaction_id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_meter_values_transaction_id'), table_name='meter_values')
|
||||
op.drop_index(op.f('ix_meter_values_timestamp'), table_name='meter_values')
|
||||
op.drop_index(op.f('ix_meter_values_measurand'), table_name='meter_values')
|
||||
op.drop_table('meter_values')
|
||||
op.drop_index(op.f('ix_transactions_user_id'), table_name='transactions')
|
||||
op.drop_index(op.f('ix_transactions_status'), table_name='transactions')
|
||||
op.drop_index(op.f('ix_transactions_started_at'), table_name='transactions')
|
||||
op.drop_index(op.f('ix_transactions_ended_at'), table_name='transactions')
|
||||
op.drop_index(op.f('ix_transactions_chargepoint_id'), table_name='transactions')
|
||||
op.drop_table('transactions')
|
||||
op.drop_table('connectors')
|
||||
op.drop_index(op.f('ix_chargepoint_variables_chargepoint_id'), table_name='chargepoint_variables')
|
||||
op.drop_table('chargepoint_variables')
|
||||
op.drop_index(op.f('ix_id_tokens_token'), table_name='id_tokens')
|
||||
op.drop_table('id_tokens')
|
||||
op.drop_index(op.f('ix_chargepoints_identity'), table_name='chargepoints')
|
||||
op.drop_table('chargepoints')
|
||||
op.drop_index(op.f('ix_users_friendly_name'), table_name='users')
|
||||
op.drop_table('users')
|
||||
# ### end Alembic commands ###
|
|
@ -0,0 +1,59 @@
|
|||
"""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
|
||||
|
||||
|
||||
# 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))
|
||||
op.add_column('users', sa.Column('role', sa.Enum('MEMBER', 'ADMINISTRATOR', name='role'), 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 ###
|
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
88
backend/app/cli.py
Normal file
88
backend/app/cli.py
Normal file
|
@ -0,0 +1,88 @@
|
|||
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
|
||||
|
||||
from app.models import *
|
||||
|
||||
load_dotenv()
|
||||
|
||||
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)
|
24
backend/app/database.py
Normal file
24
backend/app/database.py
Normal file
|
@ -0,0 +1,24 @@
|
|||
import os
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
SQLALCHEMY_DATABASE_URL = os.getenv("CS_DATABASE_URL", "sqlite:///./simple-ocpp-cs.db")
|
||||
|
||||
if SQLALCHEMY_DATABASE_URL.startswith("sqlite"):
|
||||
engine = create_engine(
|
||||
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
|
||||
)
|
||||
else:
|
||||
engine = create_engine(SQLALCHEMY_DATABASE_URL)
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
51
backend/app/main.py
Normal file
51
backend/app/main.py
Normal file
|
@ -0,0 +1,51 @@
|
|||
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",
|
||||
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()
|
10
backend/app/models/__init__.py
Normal file
10
backend/app/models/__init__.py
Normal file
|
@ -0,0 +1,10 @@
|
|||
__all__ = [
|
||||
"chargepoint_variable",
|
||||
"chargepoint",
|
||||
"connector",
|
||||
"id_token",
|
||||
"meter_value",
|
||||
"session",
|
||||
"transaction",
|
||||
"user"
|
||||
]
|
27
backend/app/models/chargepoint.py
Normal file
27
backend/app/models/chargepoint.py
Normal file
|
@ -0,0 +1,27 @@
|
|||
import uuid
|
||||
from sqlalchemy import ForeignKey, Numeric, Uuid, Boolean, Column, DateTime, String
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
class ChargePoint(Base):
|
||||
__tablename__ = "chargepoints"
|
||||
|
||||
id = Column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
identity = Column(String, unique=True, index=True)
|
||||
is_active = Column(Boolean, default=True)
|
||||
password = Column(String)
|
||||
price = Column(Numeric(10, 2))
|
||||
|
||||
last_seen = Column(DateTime, nullable=True)
|
||||
vendor_name = Column(String, nullable=True)
|
||||
model = Column(String, nullable=True)
|
||||
serial_number = Column(String, nullable=True)
|
||||
firmware_version = Column(String, nullable=True)
|
||||
|
||||
learn_user_id = Column(Uuid, ForeignKey("users.id"), nullable=True)
|
||||
learn_until = Column(DateTime, nullable=True)
|
||||
|
||||
connectors = relationship("Connector", cascade="delete, delete-orphan")
|
||||
transactions = relationship("Transaction", cascade="delete, delete-orphan")
|
||||
variables = relationship("ChargepointVariable", cascade="delete, delete-orphan")
|
29
backend/app/models/chargepoint_variable.py
Normal file
29
backend/app/models/chargepoint_variable.py
Normal file
|
@ -0,0 +1,29 @@
|
|||
from dataclasses import dataclass
|
||||
import uuid
|
||||
from sqlalchemy import ForeignKey, Integer, Numeric, Uuid, Boolean, Column, String, Enum
|
||||
|
||||
from app.database import Base
|
||||
from app.schemas.chargepoint_variable import AttributeType, MutabilityType, DataType
|
||||
|
||||
@dataclass
|
||||
class ChargepointVariable(Base):
|
||||
__tablename__ = "chargepoint_variables"
|
||||
|
||||
id = Column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
name = Column(String)
|
||||
type = Column(Enum(AttributeType), default=AttributeType.ACTUAL)
|
||||
value = Column(String, nullable=True)
|
||||
mutability = Column(Enum(MutabilityType), default=MutabilityType.READ_WRITE)
|
||||
persistent = Column(Boolean, default=False)
|
||||
constant = Column(Boolean, default=False)
|
||||
unit = Column(String, nullable=True)
|
||||
data_type = Column(Enum(DataType), nullable=True)
|
||||
min_limit = Column(Numeric, nullable=True)
|
||||
max_limit = Column(Numeric, nullable=True)
|
||||
values_list = Column(String, nullable=True)
|
||||
component_name = Column(String)
|
||||
component_instance = Column(String, nullable=True)
|
||||
evse = Column(Integer, nullable=True)
|
||||
connector_id = Column(Integer, nullable=True)
|
||||
|
||||
chargepoint_id = Column(Uuid, ForeignKey("chargepoints.id"), index=True)
|
15
backend/app/models/connector.py
Normal file
15
backend/app/models/connector.py
Normal file
|
@ -0,0 +1,15 @@
|
|||
import uuid
|
||||
from sqlalchemy import Uuid, Column, Enum, ForeignKey, Integer
|
||||
|
||||
from app.schemas.connector import ConnectorStatus
|
||||
from app.database import Base
|
||||
|
||||
class Connector(Base):
|
||||
__tablename__ = "connectors"
|
||||
|
||||
id = Column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
evse = Column(Integer)
|
||||
index = Column(Integer)
|
||||
status = Column(Enum(ConnectorStatus))
|
||||
|
||||
chargepoint_id = Column(Uuid, ForeignKey("chargepoints.id"))
|
16
backend/app/models/id_token.py
Normal file
16
backend/app/models/id_token.py
Normal file
|
@ -0,0 +1,16 @@
|
|||
import uuid
|
||||
from sqlalchemy import Uuid, Boolean, Column, ForeignKey, String
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
class IdToken(Base):
|
||||
__tablename__ = "id_tokens"
|
||||
|
||||
id = Column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
friendly_name = Column(String)
|
||||
is_active = Column(Boolean, default=True)
|
||||
token = Column(String, index=True)
|
||||
|
||||
owner_id = Column(Uuid, ForeignKey("users.id"))
|
||||
owner = relationship("User", back_populates="id_tokens")
|
17
backend/app/models/meter_value.py
Normal file
17
backend/app/models/meter_value.py
Normal file
|
@ -0,0 +1,17 @@
|
|||
import uuid
|
||||
from sqlalchemy import Uuid, Column, DateTime, Enum, Float, ForeignKey, String
|
||||
|
||||
from app.database import Base
|
||||
from app.schemas.meter_value import Measurand, PhaseType
|
||||
|
||||
class MeterValue(Base):
|
||||
__tablename__ = "meter_values"
|
||||
|
||||
id = Column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
timestamp = Column(DateTime, index=True)
|
||||
measurand = Column(Enum(Measurand), index=True)
|
||||
phase_type = Column(Enum(PhaseType), nullable=True)
|
||||
unit = Column(String, nullable=True)
|
||||
value = Column(Float)
|
||||
|
||||
transaction_id = Column(String, ForeignKey("transactions.id"), index=True)
|
14
backend/app/models/session.py
Normal file
14
backend/app/models/session.py
Normal file
|
@ -0,0 +1,14 @@
|
|||
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, unique=True, index=True)
|
||||
last_used = Column(DateTime(timezone=True))
|
||||
|
||||
user_id = Column(Uuid, ForeignKey("users.id"), index=True)
|
19
backend/app/models/transaction.py
Normal file
19
backend/app/models/transaction.py
Normal file
|
@ -0,0 +1,19 @@
|
|||
from sqlalchemy import String, Uuid, Column, DateTime, Enum, Numeric, ForeignKey
|
||||
|
||||
from app.schemas.transaction import TransactionEventTriggerReason, TransactionStatus
|
||||
from app.database import Base
|
||||
|
||||
class Transaction(Base):
|
||||
__tablename__ = "transactions"
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
status = Column(Enum(TransactionStatus), index=True)
|
||||
started_at = Column(DateTime, index=True)
|
||||
ended_at = Column(DateTime, nullable=True, index=True)
|
||||
meter_start = Column(Numeric(10,2))
|
||||
meter_end = Column(Numeric(10,2), nullable=True)
|
||||
end_reason = Column(Enum(TransactionEventTriggerReason), nullable=True)
|
||||
price = Column(Numeric(10,2))
|
||||
|
||||
user_id = Column(Uuid, ForeignKey("users.id"), nullable=True, index=True)
|
||||
chargepoint_id = Column(Uuid, ForeignKey("chargepoints.id"), index=True)
|
19
backend/app/models/user.py
Normal file
19
backend/app/models/user.py
Normal file
|
@ -0,0 +1,19 @@
|
|||
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")
|
0
backend/app/ocpp_proto/__init__.py
Normal file
0
backend/app/ocpp_proto/__init__.py
Normal file
118
backend/app/ocpp_proto/chargepoint.py
Normal file
118
backend/app/ocpp_proto/chargepoint.py
Normal file
|
@ -0,0 +1,118 @@
|
|||
from datetime import datetime, UTC
|
||||
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 app.services import (
|
||||
variable_service,
|
||||
id_token_service,
|
||||
chargepoint_service,
|
||||
transaction_service
|
||||
)
|
||||
|
||||
class ChargePoint(cp):
|
||||
|
||||
@on(Action.boot_notification)
|
||||
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(
|
||||
current_time=datetime.now(UTC).isoformat(),
|
||||
interval=int(os.getenv("CS_HEARTBEAT_INTERVAL", "1800")),
|
||||
status=RegistrationStatusEnumType.accepted
|
||||
)
|
||||
|
||||
@after(Action.boot_notification)
|
||||
async def after_boot_notification(self, **kwargs):
|
||||
await self.call(payload=GetBaseReport(request_id=0, report_base="FullInventory"))
|
||||
|
||||
@on(Action.notify_report)
|
||||
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()
|
||||
|
||||
@on(Action.heartbeat)
|
||||
async def on_heartbeat_request(self):
|
||||
return call_result.Heartbeat(
|
||||
current_time=datetime.now(UTC).isoformat()
|
||||
)
|
||||
|
||||
@after(Action.heartbeat)
|
||||
async def after_heartbeat_request(self):
|
||||
await chargepoint_service.update_last_seen(chargepoint_identity=self.id)
|
||||
|
||||
@on(Action.status_notification)
|
||||
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,
|
||||
evse_id=evse_id,
|
||||
connector_id=connector_id,
|
||||
connector_status=connector_status
|
||||
)
|
||||
return call_result.StatusNotification()
|
||||
|
||||
@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)
|
||||
|
||||
@on(Action.transaction_event)
|
||||
async def on_transaction_event(
|
||||
self,
|
||||
event_type,
|
||||
timestamp,
|
||||
trigger_reason,
|
||||
transaction_info,
|
||||
**kwargs
|
||||
):
|
||||
|
||||
if "id_token" in kwargs.keys():
|
||||
id_token_info, token_owner_id = await id_token_service.get_id_token_info(chargepoint_id=self.id, id_token=kwargs['id_token'])
|
||||
else:
|
||||
id_token_info = None
|
||||
token_owner_id = None
|
||||
|
||||
if event_type == str(TransactionEventEnumType.started):
|
||||
await transaction_service.create_transaction(
|
||||
chargepoint_identity=self.id,
|
||||
user_id=token_owner_id,
|
||||
timestamp=datetime.fromisoformat(timestamp),
|
||||
transaction_info=transaction_info,
|
||||
transaction_data=kwargs
|
||||
)
|
||||
elif event_type == str(TransactionEventEnumType.updated):
|
||||
await transaction_service.update_transaction(
|
||||
transaction_id=transaction_info["transaction_id"],
|
||||
transaction_data=kwargs
|
||||
)
|
||||
elif event_type == str(TransactionEventEnumType.ended):
|
||||
await transaction_service.end_transaction(
|
||||
transaction_id=transaction_info["transaction_id"],
|
||||
timestamp=datetime.fromisoformat(timestamp),
|
||||
trigger_reason=trigger_reason,
|
||||
transaction_data=kwargs,
|
||||
user_id=token_owner_id
|
||||
)
|
||||
|
||||
if id_token_info == None:
|
||||
return call_result.TransactionEvent()
|
||||
else:
|
||||
return call_result.TransactionEvent(id_token_info=id_token_info)
|
||||
|
||||
@on(Action.meter_values)
|
||||
async def on_meter_values(self, **kwargs):
|
||||
return call_result.MeterValues()
|
||||
|
||||
@on(Action.security_event_notification)
|
||||
async def on_security_event_notification(self, **kwargs):
|
||||
return call_result.SecurityEventNotification()
|
33
backend/app/ocpp_proto/chargepoint_manager.py
Normal file
33
backend/app/ocpp_proto/chargepoint_manager.py
Normal file
|
@ -0,0 +1,33 @@
|
|||
import logging
|
||||
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
|
||||
|
||||
__active_connections: Dict[UUID, ChargePoint] = {}
|
||||
|
||||
async def start(id: UUID, cp: ChargePoint):
|
||||
try:
|
||||
__active_connections[id] = cp
|
||||
await cp.start()
|
||||
except (ConnectionClosed, WebSocketDisconnect):
|
||||
logging.info("Charging station '%s' (%s) disconnected", cp.id, id)
|
||||
__active_connections.pop(id, None)
|
||||
|
||||
async def call(
|
||||
chargepoint_id: UUID,
|
||||
payload: Any,
|
||||
suppress: bool = True,
|
||||
unique_id: Any | None = None
|
||||
) -> Coroutine[Any, Any, Any | None]:
|
||||
try:
|
||||
cp = __active_connections[chargepoint_id]
|
||||
return await cp.call(payload, suppress, unique_id)
|
||||
except KeyError as e:
|
||||
raise e
|
||||
|
||||
def is_connected(chargepoint_id: UUID):
|
||||
return chargepoint_id in __active_connections.keys()
|
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
72
backend/app/routers/auth_v1.py
Normal file
72
backend/app/routers/auth_v1.py
Normal file
|
@ -0,0 +1,72 @@
|
|||
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), initiator=f"user:{token.subject}"
|
||||
)
|
||||
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")
|
295
backend/app/routers/chargepoint_v1.py
Normal file
295
backend/app/routers/chargepoint_v1.py
Normal file
|
@ -0,0 +1,295 @@
|
|||
import random
|
||||
import string
|
||||
from datetime import datetime, timedelta, UTC
|
||||
from uuid import UUID
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.params import Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ocpp.v201.call import Reset, SetVariables
|
||||
|
||||
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,
|
||||
ChargePointUpdate,
|
||||
ChargePointPassword,
|
||||
ChargePointConnectionInfo,
|
||||
ChargePointResetRequest,
|
||||
ChargePointResetResponse
|
||||
)
|
||||
from app.schemas.id_token import IdTokenLearnRequest, IdTokenLearnResponse
|
||||
from app.schemas.chargepoint_variable import (
|
||||
ChargepointVariable,
|
||||
ChargepointVariableUpdate,
|
||||
ChargepointVariableResponse,
|
||||
MutabilityType,
|
||||
SetVariableStatusType
|
||||
)
|
||||
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.security.jwt_bearer import JWTBearer
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/chargepoints",
|
||||
tags=["Chargepoint (v1)"],
|
||||
)
|
||||
|
||||
@router.get(path="", response_model=list[ChargePoint])
|
||||
async def get_chargepoints(
|
||||
skip: int = 0,
|
||||
limit: int = 20,
|
||||
db: Session = Depends(get_db),
|
||||
token: AccessToken = Depends(JWTBearer()),
|
||||
):
|
||||
return db.query(DbChargePoint).order_by(DbChargePoint.identity).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()),
|
||||
):
|
||||
chargepoint = db.get(DbChargePoint, chargepoint_id)
|
||||
if chargepoint is None:
|
||||
raise HTTPException(status_code=404, detail="Chargepoint not found")
|
||||
return 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"])),
|
||||
):
|
||||
chargepoint = db.get(DbChargePoint, chargepoint_id)
|
||||
if chargepoint is None:
|
||||
raise HTTPException(status_code=404, detail="Chargepoint not found")
|
||||
return ChargePointPassword(password=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"])),
|
||||
):
|
||||
chargepoint = db.get(DbChargePoint, chargepoint_id)
|
||||
if chargepoint is None:
|
||||
raise HTTPException(status_code=404, detail="Chargepoint not found")
|
||||
chargepoint.password = ''.join(random.choice(string.ascii_letters + string.digits) for i in range(24))
|
||||
db.commit()
|
||||
return ChargePointPassword(password=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"])),
|
||||
):
|
||||
chargepoint_db = DbChargePoint(
|
||||
identity=chargepoint.identity,
|
||||
is_active=chargepoint.is_active,
|
||||
password=''.join(random.choice(string.ascii_letters + string.digits) for i in range(24)),
|
||||
price=chargepoint.price
|
||||
)
|
||||
db.add(chargepoint_db)
|
||||
db.commit()
|
||||
db.refresh(chargepoint_db)
|
||||
return chargepoint_db
|
||||
|
||||
@router.patch(path="/{chargepoint_id}", response_model=ChargePoint)
|
||||
async def update_chargepoint(
|
||||
chargepoint_id: UUID,
|
||||
chargepoint_update: ChargePointUpdate,
|
||||
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")
|
||||
for key, value in chargepoint_update.model_dump(exclude_unset=True).items():
|
||||
setattr(chargepoint, key, value)
|
||||
db.commit()
|
||||
return 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"])),
|
||||
):
|
||||
chargepoint = db.get(DbChargePoint, chargepoint_id)
|
||||
if chargepoint is None:
|
||||
raise HTTPException(status_code=404, detail="Chargepoint not found")
|
||||
db.delete(chargepoint)
|
||||
db.commit()
|
||||
return []
|
||||
|
||||
@router.get(path="/{chargepoint_id}/status", response_model=ChargePointConnectionInfo)
|
||||
async def get_chargepoint_status(
|
||||
chargepoint_id: UUID,
|
||||
token: AccessToken = Depends(JWTBearer()),
|
||||
):
|
||||
return ChargePointConnectionInfo(
|
||||
connected=chargepoint_manager.is_connected(chargepoint_id)
|
||||
)
|
||||
|
||||
@router.post(path="/{chargepoint_id}/reset", response_model=ChargePointResetResponse)
|
||||
async def reset_chargepoint(
|
||||
chargepoint_id: UUID,
|
||||
reset_request: ChargePointResetRequest,
|
||||
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:
|
||||
response = await chargepoint_manager.call(
|
||||
chargepoint_id,
|
||||
payload=Reset(type=reset_request.type, evse_id=reset_request.evse_id)
|
||||
)
|
||||
return ChargePointResetResponse(status=response.status)
|
||||
except TimeoutError:
|
||||
raise HTTPException(status_code=503, detail="Chargepoint didn't respond in time.")
|
||||
|
||||
@router.post(path="/{chargepoint_id}/token-learning", status_code=201, response_model=IdTokenLearnResponse)
|
||||
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"])),
|
||||
):
|
||||
chargepoint = db.get(DbChargePoint, chargepoint_id)
|
||||
if chargepoint is None:
|
||||
raise HTTPException(status_code=404, detail="Chargepoint not found")
|
||||
|
||||
owner = db.get(DbUser, learn_request.user_id)
|
||||
if owner == None:
|
||||
raise HTTPException(status_code=422, detail=[{
|
||||
"loc": ["body", "user_id"],
|
||||
"msg": "Target user not found",
|
||||
"type": "invalid_relation"
|
||||
}])
|
||||
|
||||
chargepoint.learn_user_id = learn_request.user_id
|
||||
|
||||
if learn_request.until == None:
|
||||
chargepoint.learn_until = datetime.now(UTC) + timedelta(minutes=5)
|
||||
else:
|
||||
chargepoint.learn_until = learn_request.until
|
||||
|
||||
db.commit()
|
||||
|
||||
return IdTokenLearnResponse(
|
||||
user_id=chargepoint.learn_user_id,
|
||||
until=chargepoint.learn_until
|
||||
)
|
||||
|
||||
@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"])),
|
||||
):
|
||||
chargepoint = db.get(DbChargePoint, chargepoint_id)
|
||||
if chargepoint is None:
|
||||
raise HTTPException(status_code=404, detail="Chargepoint not found")
|
||||
|
||||
if chargepoint.learn_user_id == None:
|
||||
raise HTTPException(status_code=404, detail="No active learning request")
|
||||
|
||||
return IdTokenLearnResponse(
|
||||
user_id=chargepoint.learn_user_id,
|
||||
until=chargepoint.learn_until
|
||||
)
|
||||
|
||||
@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"])),
|
||||
):
|
||||
chargepoint = db.get(DbChargePoint, chargepoint_id)
|
||||
if chargepoint is None:
|
||||
raise HTTPException(status_code=404, detail="Chargepoint not found")
|
||||
|
||||
if chargepoint.learn_user_id == None:
|
||||
raise HTTPException(status_code=404, detail="No active learning request")
|
||||
|
||||
chargepoint.learn_user_id = None
|
||||
chargepoint.learn_until = None
|
||||
db.commit()
|
||||
|
||||
return []
|
||||
|
||||
@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"])),
|
||||
):
|
||||
chargepoint = db.get(DbChargePoint, chargepoint_id)
|
||||
if chargepoint is None:
|
||||
raise HTTPException(status_code=404, detail="Chargepoint not found")
|
||||
|
||||
return db.query(DbChargepointVariable).filter(DbChargepointVariable.chargepoint_id == chargepoint_id).all()
|
||||
|
||||
@router.put(path="/{chargepoint_id}/variables/{variable_id}", response_model=ChargepointVariableResponse)
|
||||
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"])),
|
||||
):
|
||||
chargepoint = db.get(DbChargePoint, chargepoint_id)
|
||||
if chargepoint is None:
|
||||
raise HTTPException(status_code=404, detail="Chargepoint not found")
|
||||
|
||||
variable = db.query(DbChargepointVariable).filter(
|
||||
DbChargepointVariable.chargepoint_id == chargepoint_id,
|
||||
DbChargepointVariable.id == variable_id
|
||||
).first()
|
||||
if variable is None:
|
||||
raise HTTPException(status_code=404, detail="ChargepointVariable not found")
|
||||
if variable.mutability == MutabilityType.READ_ONLY:
|
||||
raise HTTPException(status_code=422, detail="ChargepointVariable is read-only")
|
||||
|
||||
variable.value = variable_update.value
|
||||
|
||||
if chargepoint_manager.is_connected(chargepoint_id) == False:
|
||||
raise HTTPException(status_code=503, detail="Chargepoint not connected.")
|
||||
try:
|
||||
evse = None
|
||||
if variable.evse != None:
|
||||
evse = {
|
||||
'id': variable.evse
|
||||
}
|
||||
if variable.connector_id != None:
|
||||
evse['connectorId'] = variable.connector_id
|
||||
result = await chargepoint_manager.call(
|
||||
chargepoint_id,
|
||||
payload=SetVariables(set_variable_data=[
|
||||
{
|
||||
'attributeType': variable.type.value,
|
||||
'attributeValue': variable_update.value,
|
||||
'component': {
|
||||
'name': variable.component_name,
|
||||
'instance': variable.component_instance,
|
||||
'evse': evse
|
||||
},
|
||||
'variable': {
|
||||
'name': variable.name
|
||||
}
|
||||
}
|
||||
])
|
||||
)
|
||||
status = result.set_variable_result[0]['attribute_status']
|
||||
if SetVariableStatusType(status) in [SetVariableStatusType.ACCEPTED, SetVariableStatusType.REBOOT_REQUIRED]:
|
||||
db.commit()
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail=status)
|
||||
return ChargepointVariableResponse(status=status)
|
||||
except TimeoutError:
|
||||
raise HTTPException(status_code=503, detail="Chargepoint didn't respond in time.")
|
115
backend/app/routers/id_token_v1.py
Normal file
115
backend/app/routers/id_token_v1.py
Normal file
|
@ -0,0 +1,115 @@
|
|||
from uuid import UUID
|
||||
from fastapi import APIRouter, HTTPException
|
||||
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
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/id-tokens",
|
||||
tags=["IdToken (v1)"]
|
||||
)
|
||||
|
||||
@router.get(path="", response_model=list[IdToken])
|
||||
async def get_id_tokens(
|
||||
skip: int = 0,
|
||||
limit: int = 20,
|
||||
db: Session = Depends(get_db),
|
||||
token: AccessToken = Depends(JWTBearer()),
|
||||
):
|
||||
stmt = select(Session)
|
||||
if token.role != Role.ADMINISTRATOR:
|
||||
stmt = stmt.where(DbIdToken.owner_id == token.subject)
|
||||
stmt = stmt.order_by(DbIdToken.id).offset(skip).limit(limit)
|
||||
result = db.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
@router.get(path="/{id_token_id}", response_model=IdToken)
|
||||
async def get_id_token(
|
||||
id_token_id: UUID,
|
||||
db: Session = Depends(get_db),
|
||||
token: AccessToken = Depends(JWTBearer()),
|
||||
):
|
||||
stmt = select(DbIdToken).where(DbIdToken.id == id_token_id)
|
||||
result = db.execute(stmt)
|
||||
id_token = result.scalars().first()
|
||||
if id_token == None:
|
||||
raise HTTPException(status_code=404, detail="IdToken not found")
|
||||
if token.role != Role.ADMINISTRATOR & 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"])),
|
||||
):
|
||||
stmt = select(DbUser).where(DbUser.id == create_id_token.owner_id)
|
||||
result = db.execute(stmt)
|
||||
owner = result.scalars().first()
|
||||
if owner == None:
|
||||
raise HTTPException(status_code=422, detail=[{
|
||||
"loc": ["body", "owner_id"],
|
||||
"msg": "Owner not found",
|
||||
"type": "invalid_relation"
|
||||
}])
|
||||
id_token = DbIdToken(
|
||||
friendly_name=create_id_token.friendly_name,
|
||||
is_active=create_id_token.is_active,
|
||||
token=create_id_token.token,
|
||||
owner_id=create_id_token.owner_id
|
||||
)
|
||||
db.add(id_token)
|
||||
db.commit()
|
||||
db.refresh(id_token)
|
||||
return id_token
|
||||
|
||||
@router.patch(path="/{id_token_id}", response_model=IdToken)
|
||||
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"])),
|
||||
):
|
||||
stmt = select(DbIdToken).where(DbIdToken.id == id_token_id)
|
||||
result = db.execute(stmt)
|
||||
id_token = result.scalars().first()
|
||||
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()
|
||||
if owner == None:
|
||||
raise HTTPException(status_code=422, detail=[{
|
||||
"loc": ["body", "owner_id"],
|
||||
"msg": "Owner not found",
|
||||
"type": "invalid_relation"
|
||||
}])
|
||||
setattr(id_token, key, value)
|
||||
db.commit()
|
||||
return 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"])),
|
||||
):
|
||||
stmt = select(DbIdToken).where(DbIdToken.id == id_token_id)
|
||||
result = db.execute(stmt)
|
||||
id_token = result.scalars().first()
|
||||
if id_token == None:
|
||||
raise HTTPException(status_code=404, detail="IdToken not found")
|
||||
db.delete(id_token)
|
||||
db.commit()
|
||||
return []
|
111
backend/app/routers/me_v1.py
Normal file
111
backend/app/routers/me_v1.py
Normal file
|
@ -0,0 +1,111 @@
|
|||
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()
|
25
backend/app/routers/meter_value_v1.py
Normal file
25
backend/app/routers/meter_value_v1.py
Normal file
|
@ -0,0 +1,25 @@
|
|||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.schemas.auth_token import AccessToken
|
||||
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",
|
||||
tags=["MeterValue (v1)"]
|
||||
)
|
||||
|
||||
@router.get(path="", response_model=list[MeterValue])
|
||||
async def get_meter_values(
|
||||
skip: int = 0,
|
||||
limit: int = 20,
|
||||
db: Session = Depends(get_db),
|
||||
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
|
||||
):
|
||||
stmt = select(DbMeterValue).order_by(DbMeterValue.timestamp).offset(skip).limit(limit)
|
||||
result = db.execute(stmt)
|
||||
return result.scalars().all()
|
47
backend/app/routers/ocpp_v1.py
Normal file
47
backend/app/routers/ocpp_v1.py
Normal file
|
@ -0,0 +1,47 @@
|
|||
import logging
|
||||
from fastapi import APIRouter, WebSocket, WebSocketException
|
||||
|
||||
from app.ocpp_proto import chargepoint_manager
|
||||
from app.ocpp_proto.chargepoint import ChargePoint
|
||||
from app.util.websocket_wrapper import WebSocketWrapper
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.websocket("/{chargepoint_identity}")
|
||||
async def websocket_endpoint(
|
||||
*,
|
||||
websocket: WebSocket,
|
||||
chargepoint_identity: str,
|
||||
):
|
||||
""" For every new charging station that connects, create a ChargePoint
|
||||
instance and start listening for messages.
|
||||
"""
|
||||
if (websocket.user.identity != chargepoint_identity):
|
||||
raise WebSocketException(code=1008, reason="Username doesn't match chargepoint identifier")
|
||||
|
||||
logging.info("Charging station '%s' (%s) connected", chargepoint_identity, websocket.user.id)
|
||||
|
||||
# Check protocols
|
||||
try:
|
||||
requested_protocols = websocket.headers['sec-websocket-protocol']
|
||||
logging.info("Protocols advertised by charging station: %s", requested_protocols)
|
||||
except KeyError:
|
||||
logging.warning("Charging station hasn't advertised any subprotocol. "
|
||||
"Closing Connection")
|
||||
return await websocket.close()
|
||||
|
||||
if "ocpp2.0.1" in requested_protocols:
|
||||
logging.info("Matched supported protocol: ocpp2.0.1")
|
||||
else:
|
||||
logging.warning('Protocols mismatched | Expected subprotocols: %s,'
|
||||
' but client supports %s | Closing connection',
|
||||
"ocpp2.0.1",
|
||||
requested_protocols)
|
||||
await websocket.accept()
|
||||
await websocket.close()
|
||||
return
|
||||
|
||||
# Accept connection and begin communication
|
||||
await websocket.accept(subprotocol="ocpp2.0.1")
|
||||
cp = ChargePoint(chargepoint_identity, WebSocketWrapper(websocket))
|
||||
await chargepoint_manager.start(websocket.user.id, cp)
|
100
backend/app/routers/transaction_v1.py
Normal file
100
backend/app/routers/transaction_v1.py
Normal file
|
@ -0,0 +1,100 @@
|
|||
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 & 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 & 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 & 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")
|
116
backend/app/routers/user_v1.py
Normal file
116
backend/app/routers/user_v1.py
Normal file
|
@ -0,0 +1,116 @@
|
|||
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()
|
0
backend/app/schemas/__init__.py
Normal file
0
backend/app/schemas/__init__.py
Normal file
21
backend/app/schemas/auth_token.py
Normal file
21
backend/app/schemas/auth_token.py
Normal file
|
@ -0,0 +1,21 @@
|
|||
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
|
47
backend/app/schemas/chargepoint.py
Normal file
47
backend/app/schemas/chargepoint.py
Normal file
|
@ -0,0 +1,47 @@
|
|||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.schemas.connector import Connector
|
||||
|
||||
from ocpp.v201.enums import ResetEnumType, ResetStatusEnumType
|
||||
|
||||
class ChargePointBase(BaseModel):
|
||||
identity: str
|
||||
is_active: bool
|
||||
price: Decimal
|
||||
|
||||
class ChargePointUpdate(BaseModel):
|
||||
identity: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
price: Optional[Decimal]= None
|
||||
|
||||
class ChargePointCreate(ChargePointBase):
|
||||
pass
|
||||
|
||||
class ChargePoint(ChargePointBase):
|
||||
id: UUID
|
||||
last_seen: datetime | None
|
||||
vendor_name: str | None
|
||||
model: str | None
|
||||
serial_number: str | None
|
||||
firmware_version: str | None
|
||||
connectors: list[Connector] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class ChargePointPassword(BaseModel):
|
||||
password: str
|
||||
|
||||
class ChargePointConnectionInfo(BaseModel):
|
||||
connected: bool
|
||||
|
||||
class ChargePointResetRequest(BaseModel):
|
||||
type: ResetEnumType
|
||||
evse_id: Optional[int] = None
|
||||
|
||||
class ChargePointResetResponse(BaseModel):
|
||||
status: ResetStatusEnumType
|
60
backend/app/schemas/chargepoint_variable.py
Normal file
60
backend/app/schemas/chargepoint_variable.py
Normal file
|
@ -0,0 +1,60 @@
|
|||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
from pydantic import BaseModel
|
||||
import enum
|
||||
|
||||
class AttributeType(enum.Enum):
|
||||
ACTUAL = "Actual"
|
||||
TARGET = "Target"
|
||||
MIN_SET = "MinSet"
|
||||
MAX_SET = "MaxSet"
|
||||
|
||||
class MutabilityType(enum.Enum):
|
||||
READ_ONLY = "ReadOnly"
|
||||
WRITE_ONLY = "WriteOnly"
|
||||
READ_WRITE = "ReadWrite"
|
||||
|
||||
class DataType(enum.Enum):
|
||||
STRING = "string"
|
||||
DECIMAL = "decimal"
|
||||
INTEGER = "integer"
|
||||
DATETIME = "dateTime"
|
||||
BOOLEAN = "boolean"
|
||||
OPTION_LIST = "OptionList"
|
||||
SEQUENCE_LIST = "SequenceList"
|
||||
MEMBER_LIST = "MemberList"
|
||||
|
||||
class SetVariableStatusType(enum.Enum):
|
||||
ACCEPTED = "Accepted"
|
||||
REJECTED = "Rejected"
|
||||
UNKNOWN_COMPONENT = "UnknownComponent"
|
||||
NOT_SUPPORTED_ATTRIBUTE_TYPE = "NotSupportedAttributeType"
|
||||
REBOOT_REQUIRED = "RebootRequired"
|
||||
|
||||
class ChargepointVariable(BaseModel):
|
||||
id: UUID
|
||||
name: str
|
||||
type: AttributeType
|
||||
value: Optional[str] = None
|
||||
mutability: MutabilityType
|
||||
persistent: bool
|
||||
constant: bool
|
||||
unit: Optional[str] = None
|
||||
data_type: Optional[DataType] = None
|
||||
min_limit: Optional[Decimal] = None
|
||||
max_limit: Optional[Decimal] = None
|
||||
values_list: Optional[str] = None
|
||||
component_name: str
|
||||
component_instance: Optional[str] = None
|
||||
evse: Optional[int] = None
|
||||
connector_id: Optional[int] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class ChargepointVariableUpdate(BaseModel):
|
||||
value: str
|
||||
|
||||
class ChargepointVariableResponse(BaseModel):
|
||||
status: SetVariableStatusType
|
19
backend/app/schemas/connector.py
Normal file
19
backend/app/schemas/connector.py
Normal file
|
@ -0,0 +1,19 @@
|
|||
import enum
|
||||
from uuid import UUID
|
||||
from pydantic import BaseModel
|
||||
|
||||
class ConnectorStatus(enum.Enum):
|
||||
AVAILABLE = "Available"
|
||||
OCCUPIED = "Occupied"
|
||||
RESERVED = "Reserved"
|
||||
UNAVAILABLE = "Unavailable"
|
||||
FAULTED = "Faulted"
|
||||
|
||||
class Connector(BaseModel):
|
||||
id: UUID
|
||||
evse: int
|
||||
index: int
|
||||
status: ConnectorStatus
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
33
backend/app/schemas/id_token.py
Normal file
33
backend/app/schemas/id_token.py
Normal file
|
@ -0,0 +1,33 @@
|
|||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
from pydantic import BaseModel
|
||||
|
||||
class IdTokenBase(BaseModel):
|
||||
friendly_name: str
|
||||
is_active: bool
|
||||
owner_id: UUID
|
||||
token: str
|
||||
|
||||
class IdTokenCreate(IdTokenBase):
|
||||
pass
|
||||
|
||||
class IdTokenUpdate(BaseModel):
|
||||
friendly_name: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
owner_id: Optional[UUID] = None
|
||||
|
||||
class IdToken(IdTokenBase):
|
||||
id: UUID
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class IdTokenLearnBase(BaseModel):
|
||||
user_id: UUID
|
||||
|
||||
class IdTokenLearnRequest(IdTokenLearnBase):
|
||||
until: Optional[datetime] = None
|
||||
|
||||
class IdTokenLearnResponse(IdTokenLearnBase):
|
||||
until: datetime
|
57
backend/app/schemas/meter_value.py
Normal file
57
backend/app/schemas/meter_value.py
Normal file
|
@ -0,0 +1,57 @@
|
|||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
import enum
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
from pydantic import BaseModel
|
||||
|
||||
class PhaseType(enum.Enum):
|
||||
L1 = "L1"
|
||||
L2 = "L2"
|
||||
L3 = "L3"
|
||||
N = "N"
|
||||
L1_N = "L1-N"
|
||||
L2_N = "L2-N"
|
||||
L3_N = "L3-N"
|
||||
L1_L2 = "L1-L2"
|
||||
L2_L3 = "L2-L3"
|
||||
L3_L1 = "L3-L1"
|
||||
|
||||
class Measurand(enum.Enum):
|
||||
CURRENT_EXPORT = "Current.Export"
|
||||
CURRENT_IMPORT = "Current.Import"
|
||||
CURRENT_OFFERED = "Current.Offered"
|
||||
ENERGY_ACTIVE_NET = "Energy.Active.Net"
|
||||
ENERGY_ACTIVE_EXPORT_REGISTER = "Energy.Active.Export.Register"
|
||||
ENERGY_ACTIVE_IMPORT_REGISTER = "Energy.Active.Import.Register"
|
||||
ENERGY_ACTIVE_EXPORT_INTERVAL = "Energy.Active.Export.Interval"
|
||||
ENERGY_ACTIVE_IMPORT_INTERVAL = "Energy.Active.Import.Interval"
|
||||
ENERGY_REACTIVE_NET = "Energy.Reactive.Net"
|
||||
ENERGY_REACTIVE_EXPORT_REGISTER = "Energy.Reactive.Export.Register"
|
||||
ENERGY_REACTIVE_IMPORT_REGISTER = "Energy.Reactive.Import.Register"
|
||||
ENERGY_REACTIVE_EXPORT_INTERVAL = "Energy.Reactive.Export.Interval"
|
||||
ENERGY_REACTIVE_IMPORT_INTERVAL = "Energy.Reactive.Import.Interval"
|
||||
ENERGY_APPARENT_NET = "Energy.Apparent.Net"
|
||||
ENERGY_APPARENT_IMPORT = "Energy.Apparent.Import"
|
||||
ENERGY_APPARENT_EXPORT = "Energy.Apparent.Export"
|
||||
FREQUENCY = "Frequency"
|
||||
POWER_ACTIVE_EXPORT = "Power.Active.Export"
|
||||
POWER_ACTIVE_IMPORT = "Power.Active.Import"
|
||||
POWER_FACTOR = "Power.Factor"
|
||||
POWER_OFFERED = "Power.Offered"
|
||||
POWER_REACTIVE_EXPORT = "Power.Reactive.Export"
|
||||
POWER_REACTIVE_IMPORT = "Power.Reactive.Import"
|
||||
SOC = "SoC"
|
||||
VOLTAGE = "Voltage"
|
||||
|
||||
class MeterValue(BaseModel):
|
||||
id: UUID
|
||||
timestamp: datetime
|
||||
measurand: Measurand
|
||||
phase_type: Optional[PhaseType] = None
|
||||
unit: Optional[str] = None
|
||||
value: Decimal
|
||||
transaction_id: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
11
backend/app/schemas/session.py
Normal file
11
backend/app/schemas/session.py
Normal file
|
@ -0,0 +1,11 @@
|
|||
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}
|
55
backend/app/schemas/transaction.py
Normal file
55
backend/app/schemas/transaction.py
Normal file
|
@ -0,0 +1,55 @@
|
|||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
import enum
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
from pydantic import BaseModel
|
||||
|
||||
class TransactionStatus(enum.Enum):
|
||||
ONGOING = "ongoing"
|
||||
ENDED = "ended"
|
||||
|
||||
class TransactionEventTriggerReason(enum.Enum):
|
||||
AUTHORIZED = "Authorized"
|
||||
CABLE_PLUGGED_IN = "CablePluggedIn"
|
||||
CHARGING_RATE_CHANGED = "ChargingRateChanged"
|
||||
CHARGING_STATE_CHANGED = "ChargingStateChanged"
|
||||
DEAUTHORIZED = "Deauthorized"
|
||||
ENERGY_LIMIT_REACHED = "EnergyLimitReached"
|
||||
EV_COMMUNICATION_LOST = "EVCommunicationLost"
|
||||
EV_CONNECT_TIMEOUT = "EVConnectTimeout"
|
||||
METER_VALUE_CLOCK = "MeterValueClock"
|
||||
METER_VALUE_PERIODIC = "MeterValuePeriodic"
|
||||
TIME_LIMIT_REACHED = "TimeLimitReached"
|
||||
TRIGGER = "Trigger"
|
||||
UNLOCK_COMMAND = "UnlockCommand"
|
||||
STOP_AUTHORIZED = "StopAuthorized"
|
||||
EV_DEPARTED = "EVDeparted"
|
||||
EV_DETECTED = "EVDetected"
|
||||
REMOTE_STOP = "RemoteStop"
|
||||
REMOTE_START = "RemoteStart"
|
||||
ABNORMAL_CONDITION = "AbnormalCondition"
|
||||
SIGNED_DATA_RECEIVED = "SignedDataReceived"
|
||||
RESET_COMMAND = "ResetCommand"
|
||||
|
||||
class RemoteTransactionStartStopStatus(enum.Enum):
|
||||
ACCEPTED = "Accepted"
|
||||
REJECTED = "Rejected"
|
||||
|
||||
class Transaction(BaseModel):
|
||||
id: str
|
||||
status: TransactionStatus
|
||||
started_at: datetime
|
||||
ended_at: Optional[datetime] = None
|
||||
meter_start: Decimal
|
||||
meter_end: Optional[Decimal] = None
|
||||
end_reason: Optional[TransactionEventTriggerReason] = None
|
||||
price: Decimal
|
||||
user_id: Optional[UUID] = None
|
||||
chargepoint_id: UUID
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class RemoteTransactionStartStopResponse(BaseModel):
|
||||
status: RemoteTransactionStartStopStatus
|
39
backend/app/schemas/user.py
Normal file
39
backend/app/schemas/user.py
Normal file
|
@ -0,0 +1,39 @@
|
|||
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)
|
0
backend/app/security/__init__.py
Normal file
0
backend/app/security/__init__.py
Normal file
47
backend/app/security/jwt_bearer.py
Normal file
47
backend/app/security/jwt_bearer.py
Normal file
|
@ -0,0 +1,47 @@
|
|||
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")
|
33
backend/app/security/websocket_auth_backend.py
Normal file
33
backend/app/security/websocket_auth_backend.py
Normal file
|
@ -0,0 +1,33 @@
|
|||
import base64
|
||||
import binascii
|
||||
from starlette.authentication import (
|
||||
AuthCredentials, AuthenticationBackend, AuthenticationError
|
||||
)
|
||||
|
||||
from app.database import SessionLocal
|
||||
from app.models.chargepoint import ChargePoint
|
||||
|
||||
class BasicAuthBackend(AuthenticationBackend):
|
||||
async def authenticate(self, conn):
|
||||
if "Authorization" not in conn.headers:
|
||||
raise AuthenticationError('No Authorization header provided')
|
||||
|
||||
auth = conn.headers["Authorization"]
|
||||
try:
|
||||
scheme, credentials = auth.split()
|
||||
if scheme.lower() != 'basic':
|
||||
raise AuthenticationError('Invalid authorization scheme')
|
||||
decoded = base64.b64decode(credentials).decode("ascii")
|
||||
except (ValueError, UnicodeDecodeError, binascii.Error) as exc:
|
||||
raise AuthenticationError('Invalid basic auth credentials')
|
||||
|
||||
username, _, password = decoded.partition(":")
|
||||
|
||||
with SessionLocal() as db:
|
||||
chargepoint = db.query(ChargePoint).filter(ChargePoint.identity == username).first()
|
||||
if chargepoint is None:
|
||||
raise AuthenticationError('Invalid basic auth credentials')
|
||||
if chargepoint.password != password:
|
||||
raise AuthenticationError('Invalid basic auth credentials')
|
||||
|
||||
return AuthCredentials(["authenticated"]), chargepoint
|
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
46
backend/app/services/chargepoint_service.py
Normal file
46
backend/app/services/chargepoint_service.py
Normal file
|
@ -0,0 +1,46 @@
|
|||
from datetime import datetime, UTC
|
||||
|
||||
from app.database import SessionLocal
|
||||
from app.models.chargepoint import ChargePoint
|
||||
from app.models.connector import Connector
|
||||
|
||||
from app.schemas.connector import ConnectorStatus
|
||||
|
||||
async def update_last_seen(chargepoint_identity: str):
|
||||
with SessionLocal() as db:
|
||||
db_chargepoint = db.query(ChargePoint).filter(ChargePoint.identity == chargepoint_identity).first()
|
||||
db_chargepoint.last_seen = datetime.now(UTC)
|
||||
db.commit()
|
||||
|
||||
async def update_attributes(chargepoint_identity: str, charging_station):
|
||||
with SessionLocal() as db:
|
||||
db_chargepoint = db.query(ChargePoint).filter(ChargePoint.identity == chargepoint_identity).first()
|
||||
for key in charging_station.keys():
|
||||
if key in db_chargepoint.__dict__:
|
||||
setattr(db_chargepoint, key, charging_station[key])
|
||||
db.commit()
|
||||
|
||||
async def create_or_update_connector(
|
||||
chargepoint_identity: str,
|
||||
evse_id: int,
|
||||
connector_id: int,
|
||||
connector_status: str
|
||||
):
|
||||
with SessionLocal() as db:
|
||||
db_chargepoint = db.query(ChargePoint).filter(ChargePoint.identity == chargepoint_identity).first()
|
||||
db_connector = db.query(Connector).filter(
|
||||
Connector.chargepoint_id == db_chargepoint.id,
|
||||
Connector.evse == evse_id,
|
||||
Connector.index == connector_id
|
||||
).first()
|
||||
if db_connector == None:
|
||||
db_connector = Connector(
|
||||
chargepoint_id = db_chargepoint.id,
|
||||
evse = evse_id,
|
||||
index = connector_id,
|
||||
status = ConnectorStatus(connector_status)
|
||||
)
|
||||
db.add(db_connector)
|
||||
else:
|
||||
db_connector.status = ConnectorStatus(connector_status)
|
||||
db.commit()
|
51
backend/app/services/id_token_service.py
Normal file
51
backend/app/services/id_token_service.py
Normal file
|
@ -0,0 +1,51 @@
|
|||
from datetime import datetime, UTC
|
||||
|
||||
from ocpp.v201.datatypes import IdTokenInfoType
|
||||
from ocpp.v201.enums import AuthorizationStatusEnumType
|
||||
|
||||
from app.database import SessionLocal
|
||||
from app.models.id_token import IdToken
|
||||
from app.models.chargepoint import ChargePoint
|
||||
|
||||
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
|
||||
), 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
|
||||
)
|
||||
db_chargepoint = db.query(ChargePoint).filter(ChargePoint.identity == chargepoint_id).first()
|
||||
# Learn token if requested
|
||||
if db_chargepoint.learn_user_id != None:
|
||||
if db_chargepoint.learn_until.timestamp() > datetime.now(UTC).timestamp():
|
||||
db_id_token = IdToken()
|
||||
db_id_token.friendly_name = "New token learned by {}".format(chargepoint_id)
|
||||
db_id_token.is_active = True
|
||||
db_id_token.owner_id = db_chargepoint.learn_user_id
|
||||
db_id_token.token = id_token["id_token"]
|
||||
db.add(db_id_token)
|
||||
|
||||
id_token_info=IdTokenInfoType(
|
||||
status=AuthorizationStatusEnumType.accepted
|
||||
)
|
||||
owner_id = db_id_token.owner_id
|
||||
db_chargepoint.learn_user_id = None
|
||||
db_chargepoint.learn_until = None
|
||||
db.commit()
|
||||
else:
|
||||
owner_id = db_id_token.owner_id
|
||||
if db_id_token.is_active == False:
|
||||
id_token_info=IdTokenInfoType(
|
||||
status=AuthorizationStatusEnumType.blocked
|
||||
)
|
||||
else:
|
||||
id_token_info=IdTokenInfoType(
|
||||
status=AuthorizationStatusEnumType.accepted
|
||||
)
|
||||
return id_token_info, owner_id
|
30
backend/app/services/meter_value_service.py
Normal file
30
backend/app/services/meter_value_service.py
Normal file
|
@ -0,0 +1,30 @@
|
|||
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from app.database import SessionLocal
|
||||
from app.models.meter_value import MeterValue
|
||||
|
||||
from app.schemas.meter_value import Measurand, PhaseType
|
||||
|
||||
async def create_meter_value(transaction_id: UUID, meter_value_data):
|
||||
with SessionLocal() as db:
|
||||
timestamp = datetime.fromisoformat(meter_value_data['timestamp'])
|
||||
for sampled_value in meter_value_data['sampled_value']:
|
||||
db_meter_value = MeterValue()
|
||||
db_meter_value.transaction_id = transaction_id
|
||||
db_meter_value.timestamp = timestamp
|
||||
if "measurand" in sampled_value.keys():
|
||||
db_meter_value.measurand = Measurand(sampled_value['measurand'])
|
||||
else:
|
||||
db_meter_value.measurand = Measurand.ENERGY_ACTIVE_IMPORT_REGISTER
|
||||
if "phase" in sampled_value.keys():
|
||||
db_meter_value.phase_type = PhaseType(sampled_value['phase'])
|
||||
if "unit_of_measure" in sampled_value.keys():
|
||||
if "unit" in sampled_value['unit_of_measure']:
|
||||
db_meter_value.unit = sampled_value['unit_of_measure']['unit']
|
||||
else:
|
||||
db_meter_value.unit = "Wh"
|
||||
db_meter_value.value = sampled_value['value']
|
||||
db.add(db_meter_value)
|
||||
db.commit()
|
83
backend/app/services/session_service.py
Normal file
83
backend/app/services/session_service.py
Normal file
|
@ -0,0 +1,83 @@
|
|||
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
|
69
backend/app/services/token_service.py
Normal file
69
backend/app/services/token_service.py
Normal file
|
@ -0,0 +1,69 @@
|
|||
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
|
86
backend/app/services/transaction_service.py
Normal file
86
backend/app/services/transaction_service.py
Normal file
|
@ -0,0 +1,86 @@
|
|||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from app.database import SessionLocal
|
||||
from app.models.chargepoint import ChargePoint
|
||||
from app.models.transaction import Transaction
|
||||
|
||||
from app.schemas.meter_value import Measurand
|
||||
from app.schemas.transaction import TransactionEventTriggerReason, TransactionStatus
|
||||
|
||||
from app.services import meter_value_service
|
||||
|
||||
async def create_transaction(
|
||||
chargepoint_identity: str,
|
||||
user_id: UUID,
|
||||
timestamp: datetime,
|
||||
transaction_info,
|
||||
transaction_data
|
||||
):
|
||||
with SessionLocal() as db:
|
||||
chargepoint = db.query(ChargePoint).filter(ChargePoint.identity == chargepoint_identity).first()
|
||||
meter_start=0
|
||||
if "meter_value" in transaction_data.keys():
|
||||
for meter_value_entry in transaction_data['meter_value']:
|
||||
for sampled_value in meter_value_entry['sampled_value']:
|
||||
if "measurand" in sampled_value.keys():
|
||||
if sampled_value['measurand'] == str(Measurand.ENERGY_ACTIVE_IMPORT_REGISTER):
|
||||
meter_start = sampled_value['value']
|
||||
else:
|
||||
meter_start = sampled_value['value']
|
||||
transaction = Transaction(
|
||||
id=transaction_info["transaction_id"],
|
||||
status=TransactionStatus.ONGOING,
|
||||
started_at=timestamp,
|
||||
meter_start=meter_start,
|
||||
price=chargepoint.price,
|
||||
chargepoint_id=chargepoint.id,
|
||||
user_id=user_id
|
||||
)
|
||||
db.add(transaction)
|
||||
db.commit()
|
||||
|
||||
async def update_transaction(
|
||||
transaction_id: str,
|
||||
transaction_data
|
||||
):
|
||||
with SessionLocal() as db:
|
||||
transaction = db.get(Transaction, transaction_id)
|
||||
if transaction != None:
|
||||
if transaction.status == TransactionStatus.ONGOING:
|
||||
if "meter_value" in transaction_data.keys():
|
||||
for meter_value_entry in transaction_data['meter_value']:
|
||||
await meter_value_service.create_meter_value(
|
||||
transaction_id=transaction.id,
|
||||
meter_value_data=meter_value_entry
|
||||
)
|
||||
|
||||
async def end_transaction(
|
||||
transaction_id: str,
|
||||
timestamp: datetime,
|
||||
trigger_reason: str,
|
||||
transaction_data,
|
||||
user_id: Optional[UUID]
|
||||
):
|
||||
with SessionLocal() as db:
|
||||
transaction = db.get(Transaction, transaction_id)
|
||||
if transaction != None:
|
||||
meter_end=0
|
||||
if "meter_value" in transaction_data.keys():
|
||||
for meter_value_entry in transaction_data['meter_value']:
|
||||
for sampled_value in meter_value_entry['sampled_value']:
|
||||
if "measurand" in sampled_value.keys():
|
||||
if sampled_value['measurand'] == str(Measurand.ENERGY_ACTIVE_IMPORT_REGISTER):
|
||||
meter_end = sampled_value['value']
|
||||
else:
|
||||
meter_end = sampled_value['value']
|
||||
|
||||
transaction.status = TransactionStatus.ENDED
|
||||
transaction.ended_at = timestamp
|
||||
transaction.end_reason = TransactionEventTriggerReason(trigger_reason)
|
||||
transaction.meter_end = meter_end
|
||||
|
||||
if user_id != None:
|
||||
transaction.user_id = user_id
|
||||
db.commit()
|
111
backend/app/services/user_service.py
Normal file
111
backend/app/services/user_service.py
Normal file
|
@ -0,0 +1,111 @@
|
|||
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
|
66
backend/app/services/variable_service.py
Normal file
66
backend/app/services/variable_service.py
Normal file
|
@ -0,0 +1,66 @@
|
|||
from decimal import Decimal
|
||||
|
||||
from app.database import SessionLocal
|
||||
from app.models.chargepoint import ChargePoint
|
||||
from app.models.chargepoint_variable import ChargepointVariable
|
||||
|
||||
from app.schemas.chargepoint_variable import AttributeType, DataType, MutabilityType
|
||||
|
||||
async def create_or_update_variable(chargepoint_identity: str, report_entry):
|
||||
with SessionLocal() as db:
|
||||
db_chargepoint = db.query(ChargePoint).filter(ChargePoint.identity == chargepoint_identity).first()
|
||||
for variable_attribute in report_entry['variable_attribute']:
|
||||
query = db.query(ChargepointVariable).filter(
|
||||
ChargepointVariable.chargepoint_id == db_chargepoint.id,
|
||||
ChargepointVariable.component_name == report_entry['component']['name'],
|
||||
ChargepointVariable.name == report_entry['variable']['name']
|
||||
)
|
||||
if "instance" in report_entry['component'].keys():
|
||||
query = query.filter(ChargepointVariable.component_instance == report_entry['component']['instance'])
|
||||
if "evse" in report_entry['component'].keys():
|
||||
query = query.filter(ChargepointVariable.evse == report_entry['component']['evse']['id'])
|
||||
if "connectorId" in report_entry['component']['evse'].keys():
|
||||
query = query.filter(ChargepointVariable.connector_id == report_entry['component']['evse']['connectorId'])
|
||||
if "type" in variable_attribute.keys():
|
||||
query = query.filter(ChargepointVariable.type == AttributeType(variable_attribute['type']))
|
||||
else:
|
||||
query = query.filter(ChargepointVariable.type == AttributeType.ACTUAL)
|
||||
db_variable = query.first()
|
||||
if db_variable == None:
|
||||
db_variable = ChargepointVariable()
|
||||
db_variable.chargepoint_id = db_chargepoint.id
|
||||
db_variable.component_name = report_entry['component']['name']
|
||||
db_variable.name = report_entry['variable']['name']
|
||||
|
||||
if "value" in variable_attribute.keys():
|
||||
db_variable.value = variable_attribute['value']
|
||||
if "instance" in report_entry['component'].keys():
|
||||
db_variable.component_instance = report_entry['component']['instance']
|
||||
if "evse" in report_entry['component'].keys():
|
||||
db_variable.evse = report_entry['component']['evse']['id']
|
||||
if "connector_id" in report_entry['component']['evse'].keys():
|
||||
db_variable.connector_id = report_entry['component']['evse']['connector_id']
|
||||
if "constant" in variable_attribute.keys():
|
||||
db_variable.constant = variable_attribute['constant']
|
||||
if "persistent" in variable_attribute.keys():
|
||||
db_variable.constant = variable_attribute['persistent']
|
||||
if "mutability" in variable_attribute.keys():
|
||||
db_variable.mutability = MutabilityType(variable_attribute['mutability'])
|
||||
if "type" in variable_attribute.keys():
|
||||
db_variable.type = AttributeType(variable_attribute['type'])
|
||||
if "variable_characteristics" in report_entry.keys():
|
||||
db_variable.data_type = DataType(report_entry['variable_characteristics']['data_type'])
|
||||
if "min_limit" in report_entry['variable_characteristics'].keys():
|
||||
db_variable.min_limit = Decimal(report_entry['variable_characteristics']['min_limit'])
|
||||
if "max_limit" in report_entry['variable_characteristics'].keys():
|
||||
db_variable.max_limit = Decimal(report_entry['variable_characteristics']['max_limit'])
|
||||
if "unit" in report_entry['variable_characteristics'].keys():
|
||||
db_variable.unit = report_entry['variable_characteristics']['unit']
|
||||
if "values_list" in report_entry['variable_characteristics'].keys():
|
||||
db_variable.values_list = report_entry['variable_characteristics']['values_list']
|
||||
db.add(db_variable)
|
||||
else:
|
||||
if "value" in variable_attribute.keys():
|
||||
db_variable.value = variable_attribute['value']
|
||||
db.commit()
|
||||
|
0
backend/app/util/__init__.py
Normal file
0
backend/app/util/__init__.py
Normal file
14
backend/app/util/errors.py
Normal file
14
backend/app/util/errors.py
Normal file
|
@ -0,0 +1,14 @@
|
|||
class NotFoundError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidStateError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class InsufficientPermissionsError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidTokenAudienceError(Exception):
|
||||
pass
|
25
backend/app/util/websocket_wrapper.py
Normal file
25
backend/app/util/websocket_wrapper.py
Normal file
|
@ -0,0 +1,25 @@
|
|||
import logging
|
||||
from fastapi import WebSocket, WebSocketDisconnect
|
||||
from websockets import ConnectionClosed
|
||||
|
||||
logger = logging.getLogger("gunicorn.error")
|
||||
|
||||
# Wrapper to transform a FastAPI websocket to a standard websocket
|
||||
class WebSocketWrapper():
|
||||
def __init__(self, websocket: WebSocket):
|
||||
self._websocket = websocket
|
||||
|
||||
async def recv(self) -> str:
|
||||
try:
|
||||
text = await self._websocket.receive_text()
|
||||
logger.info("Message received: %s", text)
|
||||
return text
|
||||
except WebSocketDisconnect as e:
|
||||
raise ConnectionClosed(e.code, 'WebSocketWrapper')
|
||||
|
||||
async def send(self, msg: str) -> None:
|
||||
logger.info("Message sent: %s", msg)
|
||||
await self._websocket.send_text(msg)
|
||||
|
||||
async def close(self, code: int, reason: str) -> None:
|
||||
await self._websocket.close(code)
|
1772
backend/poetry.lock
generated
Normal file
1772
backend/poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
24
backend/pyproject.toml
Normal file
24
backend/pyproject.toml
Normal file
|
@ -0,0 +1,24 @@
|
|||
[tool.poetry]
|
||||
name = "simple-ocpp-cs"
|
||||
version = "0.2.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"
|
||||
sqlalchemy = "^2.0.28"
|
||||
alembic = "^1.15.1"
|
||||
gunicorn = "^22.0.0"
|
||||
psycopg2-binary = "^2.9.10"
|
||||
argon2-cffi = "^23.1.0"
|
||||
jwcrypto = "^1.5.6"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core"]
|
||||
build-backend = "poetry.core.masonry.api"
|
Loading…
Add table
Add a link
Reference in a new issue