Frontend_Fixes #3
Generated
+3
@@ -0,0 +1,3 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
Generated
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="AndroidProjectSystem">
|
||||||
|
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="CompilerConfiguration">
|
||||||
|
<bytecodeTargetLevel target="21" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="deploymentTargetSelector">
|
||||||
|
<selectionStates>
|
||||||
|
<SelectionState runConfigName="app">
|
||||||
|
<option name="selectionMode" value="DROPDOWN" />
|
||||||
|
<DialogSelection />
|
||||||
|
</SelectionState>
|
||||||
|
</selectionStates>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+13
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="DeviceTable">
|
||||||
|
<option name="columnSorters">
|
||||||
|
<list>
|
||||||
|
<ColumnSorterState>
|
||||||
|
<option name="column" value="Name" />
|
||||||
|
<option name="order" value="ASCENDING" />
|
||||||
|
</ColumnSorterState>
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+10
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectMigrations">
|
||||||
|
<option name="MigrateToGradleLocalJavaHome">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+10
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||||
|
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||||
|
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||||
|
</component>
|
||||||
|
<component name="ProjectType">
|
||||||
|
<option name="id" value="Android" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+13
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="PlanningModeManager">
|
||||||
|
<option name="approvalStates">
|
||||||
|
<map>
|
||||||
|
<entry key="20260630-090134-081bb55a-1053-43ab-950d-e13c6adcdde9" value="true" />
|
||||||
|
<entry key="20260630-090415-b044c503-d35a-4112-8a57-02da5e73f9d9" value="true" />
|
||||||
|
<entry key="20260630-090515-c7bb6ec8-96fa-4399-98d4-5e18af72de07" value="true" />
|
||||||
|
<entry key="20260630-090813-d485fedc-ebae-4bad-85e8-8c07f2842ed0" value="true" />
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+17
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="RunConfigurationProducerService">
|
||||||
|
<option name="ignoredProducers">
|
||||||
|
<set>
|
||||||
|
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
|
||||||
|
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
|
||||||
|
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
|
||||||
|
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
|
||||||
|
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
|
||||||
|
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
|
||||||
|
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
|
||||||
|
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="StudioBotProjectSettings">
|
||||||
|
<option name="shareContext" value="OptedIn" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -7,10 +7,10 @@ if (keystorePropertiesFile.exists()) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace "net.hacecalor.farmafinder"
|
namespace "net.hacecalor.farmaclic"
|
||||||
compileSdk rootProject.ext.compileSdkVersion
|
compileSdk rootProject.ext.compileSdkVersion
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "net.hacecalor.farmafinder"
|
applicationId "net.hacecalor.farmaclic"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 1
|
versionCode 1
|
||||||
@@ -36,7 +36,7 @@ android {
|
|||||||
release {
|
release {
|
||||||
minifyEnabled true
|
minifyEnabled true
|
||||||
shrinkResources true
|
shrinkResources true
|
||||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||||
if (keystorePropertiesFile.exists()) {
|
if (keystorePropertiesFile.exists()) {
|
||||||
signingConfig signingConfigs.release
|
signingConfig signingConfigs.release
|
||||||
}
|
}
|
||||||
@@ -44,14 +44,8 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
repositories {
|
|
||||||
flatDir{
|
|
||||||
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
implementation fileTree(include: ['*.jar', '*.aar'], dir: 'libs')
|
||||||
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||||
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||||
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package net.hacecalor.farmafinder;
|
package net.hacecalor.farmaclic;
|
||||||
|
|
||||||
import com.getcapacitor.BridgeActivity;
|
import com.getcapacitor.BridgeActivity;
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<?xml version='1.0' encoding='utf-8'?>
|
<?xml version='1.0' encoding='utf-8'?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">FarmaFinder</string>
|
<string name="app_name">FarmaClic</string>
|
||||||
<string name="title_activity_main">FarmaFinder</string>
|
<string name="title_activity_main">FarmaClic</string>
|
||||||
<string name="package_name">net.hacecalor.farmafinder</string>
|
<string name="package_name">net.hacecalor.farmaclic</string>
|
||||||
<string name="custom_url_scheme">net.hacecalor.farmafinder</string>
|
<string name="custom_url_scheme">net.hacecalor.farmaclic</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ buildscript {
|
|||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
classpath 'com.android.tools.build:gradle:8.13.0'
|
classpath 'com.android.tools.build:gradle:9.2.1'
|
||||||
classpath 'com.google.gms:google-services:4.4.4'
|
classpath 'com.google.gms:google-services:4.4.4'
|
||||||
|
|
||||||
// NOTE: Do not place your application dependencies here; they belong
|
// NOTE: Do not place your application dependencies here; they belong
|
||||||
|
|||||||
@@ -20,3 +20,8 @@ org.gradle.jvmargs=-Xmx1536m
|
|||||||
# Android operating system, and which are packaged with your app's APK
|
# Android operating system, and which are packaged with your app's APK
|
||||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
|
android.defaults.buildfeatures.resvalues=false
|
||||||
|
android.uniquePackageNames=false
|
||||||
|
android.dependency.useConstraints=true
|
||||||
|
android.r8.strictFullModeForKeepRules=false
|
||||||
|
android.newDsl=true
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
#This file is generated by updateDaemonJvm
|
||||||
|
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
|
||||||
|
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
|
||||||
|
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
|
||||||
|
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
|
||||||
|
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/7083b89563e7ce20943037b8cd2b8cc2/redirect
|
||||||
|
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/060bbb778a1f55ea705fdebd2ccfeab9/redirect
|
||||||
|
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
|
||||||
|
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
|
||||||
|
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/d09679dc60fe5aa05ef7d03efdefac20/redirect
|
||||||
|
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/ed4e3bf2f5e7c5d9aabc4cbd8acd555e/redirect
|
||||||
|
toolchainVendor=JETBRAINS
|
||||||
|
toolchainVersion=21
|
||||||
BIN
Binary file not shown.
+3
-2
@@ -1,8 +1,9 @@
|
|||||||
#Fri Jun 26 12:32:52 CEST 2026
|
|
||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-bin.zip
|
||||||
networkTimeout=10000
|
networkTimeout=10000
|
||||||
|
retries=0
|
||||||
|
retryBackOffMs=500
|
||||||
validateDistributionUrl=true
|
validateDistributionUrl=true
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
|
|||||||
Vendored
+16
-16
@@ -1,7 +1,7 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
|
||||||
#
|
#
|
||||||
# Copyright © 2015-2021 the original authors.
|
# Copyright © 2015 the original authors.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -15,10 +15,12 @@
|
|||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
#
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
##############################################################################
|
##############################################################################
|
||||||
#
|
#
|
||||||
# Gradle start up script for POSIX generated by Gradle.
|
# gradlew start up script for POSIX generated by Gradle.
|
||||||
#
|
#
|
||||||
# Important for running:
|
# Important for running:
|
||||||
#
|
#
|
||||||
@@ -27,7 +29,7 @@
|
|||||||
# bash, then to run this script, type that shell name before the whole
|
# bash, then to run this script, type that shell name before the whole
|
||||||
# command line, like:
|
# command line, like:
|
||||||
#
|
#
|
||||||
# ksh Gradle
|
# ksh gradlew
|
||||||
#
|
#
|
||||||
# Busybox and similar reduced shells will NOT work, because this script
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
# requires all of these POSIX shell features:
|
# requires all of these POSIX shell features:
|
||||||
@@ -55,7 +57,7 @@
|
|||||||
# Darwin, MinGW, and NonStop.
|
# Darwin, MinGW, and NonStop.
|
||||||
#
|
#
|
||||||
# (3) This script is generated from the Groovy template
|
# (3) This script is generated from the Groovy template
|
||||||
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
# within the Gradle project.
|
# within the Gradle project.
|
||||||
#
|
#
|
||||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
@@ -83,7 +85,8 @@ done
|
|||||||
# This is normally unused
|
# This is normally unused
|
||||||
# shellcheck disable=SC2034
|
# shellcheck disable=SC2034
|
||||||
APP_BASE_NAME=${0##*/}
|
APP_BASE_NAME=${0##*/}
|
||||||
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||||
|
|
||||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
MAX_FD=maximum
|
MAX_FD=maximum
|
||||||
@@ -111,7 +114,6 @@ case "$( uname )" in #(
|
|||||||
NONSTOP* ) nonstop=true ;;
|
NONSTOP* ) nonstop=true ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
|
||||||
|
|
||||||
|
|
||||||
# Determine the Java command to use to start the JVM.
|
# Determine the Java command to use to start the JVM.
|
||||||
@@ -144,7 +146,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
|||||||
case $MAX_FD in #(
|
case $MAX_FD in #(
|
||||||
max*)
|
max*)
|
||||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
# shellcheck disable=SC3045
|
# shellcheck disable=SC2039,SC3045
|
||||||
MAX_FD=$( ulimit -H -n ) ||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
warn "Could not query maximum file descriptor limit"
|
warn "Could not query maximum file descriptor limit"
|
||||||
esac
|
esac
|
||||||
@@ -152,7 +154,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
|||||||
'' | soft) :;; #(
|
'' | soft) :;; #(
|
||||||
*)
|
*)
|
||||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
# shellcheck disable=SC3045
|
# shellcheck disable=SC2039,SC3045
|
||||||
ulimit -n "$MAX_FD" ||
|
ulimit -n "$MAX_FD" ||
|
||||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
esac
|
esac
|
||||||
@@ -169,7 +171,6 @@ fi
|
|||||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
if "$cygwin" || "$msys" ; then
|
if "$cygwin" || "$msys" ; then
|
||||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
|
||||||
|
|
||||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
@@ -201,16 +202,15 @@ fi
|
|||||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
# Collect all arguments for the java command;
|
# Collect all arguments for the java command:
|
||||||
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
# shell script including quotes and variable substitutions, so put them in
|
# and any embedded shellness will be escaped.
|
||||||
# double quotes to make sure that they get re-expanded; and
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
# * put everything else in single quotes, so that it's not re-expanded.
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
set -- \
|
set -- \
|
||||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
-classpath "$CLASSPATH" \
|
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||||
org.gradle.wrapper.GradleWrapperMain \
|
|
||||||
"$@"
|
"$@"
|
||||||
|
|
||||||
# Stop when "xargs" is not available.
|
# Stop when "xargs" is not available.
|
||||||
|
|||||||
Vendored
+24
-34
@@ -13,16 +13,18 @@
|
|||||||
@rem See the License for the specific language governing permissions and
|
@rem See the License for the specific language governing permissions and
|
||||||
@rem limitations under the License.
|
@rem limitations under the License.
|
||||||
@rem
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
@if "%DEBUG%"=="" @echo off
|
@if "%DEBUG%"=="" @echo off
|
||||||
@rem ##########################################################################
|
@rem ##########################################################################
|
||||||
@rem
|
@rem
|
||||||
@rem Gradle startup script for Windows
|
@rem gradlew startup script for Windows
|
||||||
@rem
|
@rem
|
||||||
@rem ##########################################################################
|
@rem ##########################################################################
|
||||||
|
|
||||||
@rem Set local scope for the variables with windows NT shell
|
@rem Set local scope for the variables, and ensure extensions are enabled
|
||||||
if "%OS%"=="Windows_NT" setlocal
|
setlocal EnableExtensions
|
||||||
|
|
||||||
set DIRNAME=%~dp0
|
set DIRNAME=%~dp0
|
||||||
if "%DIRNAME%"=="" set DIRNAME=.
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
@@ -43,13 +45,13 @@ set JAVA_EXE=java.exe
|
|||||||
%JAVA_EXE% -version >NUL 2>&1
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
if %ERRORLEVEL% equ 0 goto execute
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
echo.
|
echo. 1>&2
|
||||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
echo.
|
echo. 1>&2
|
||||||
echo Please set the JAVA_HOME variable in your environment to match the
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
echo location of your Java installation.
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
goto fail
|
"%COMSPEC%" /c exit 1
|
||||||
|
|
||||||
:findJavaFromJavaHome
|
:findJavaFromJavaHome
|
||||||
set JAVA_HOME=%JAVA_HOME:"=%
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
@@ -57,36 +59,24 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
|||||||
|
|
||||||
if exist "%JAVA_EXE%" goto execute
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
echo.
|
echo. 1>&2
|
||||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
echo.
|
echo. 1>&2
|
||||||
echo Please set the JAVA_HOME variable in your environment to match the
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
echo location of your Java installation.
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
goto fail
|
"%COMSPEC%" /c exit 1
|
||||||
|
|
||||||
:execute
|
:execute
|
||||||
@rem Setup the command line
|
@rem Setup the command line
|
||||||
|
|
||||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
|
||||||
|
|
||||||
|
|
||||||
@rem Execute Gradle
|
@rem Execute gradlew
|
||||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
|
||||||
|
@rem which allows us to clear the local environment before executing the java command
|
||||||
|
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
|
||||||
|
|
||||||
:end
|
:exitWithErrorLevel
|
||||||
@rem End local scope for the variables with windows NT shell
|
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
|
||||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
"%COMSPEC%" /c exit %ERRORLEVEL%
|
||||||
|
|
||||||
:fail
|
|
||||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
|
||||||
rem the _cmd.exe /c_ return code!
|
|
||||||
set EXIT_CODE=%ERRORLEVEL%
|
|
||||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
|
||||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
|
||||||
exit /b %EXIT_CODE%
|
|
||||||
|
|
||||||
:mainEnd
|
|
||||||
if "%OS%"=="Windows_NT" endlocal
|
|
||||||
|
|
||||||
:omega
|
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
plugins {
|
||||||
|
id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0'
|
||||||
|
}
|
||||||
include ':app'
|
include ':app'
|
||||||
include ':capacitor-cordova-android-plugins'
|
include ':capacitor-cordova-android-plugins'
|
||||||
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
|
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
|
||||||
|
|||||||
@@ -11,12 +11,7 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.app-content {
|
|
||||||
padding-bottom: calc(6rem + env(safe-area-inset-bottom));
|
padding-bottom: calc(6rem + env(safe-area-inset-bottom));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fadeInUp {
|
@keyframes fadeInUp {
|
||||||
|
|||||||
@@ -1,25 +1,22 @@
|
|||||||
.bottom-nav {
|
.bottom-nav {
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.bottom-nav {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-around;
|
justify-content: space-around;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
left: 0;
|
left: 50%;
|
||||||
right: 0;
|
transform: translateX(-50%);
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 48rem;
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
padding: 0.25rem 0.5rem calc(0.5rem + env(safe-area-inset-bottom));
|
padding: 0.25rem 0.5rem calc(0.5rem + env(safe-area-inset-bottom));
|
||||||
background: var(--surface-container-lowest);
|
background: var(--surface-container-lowest);
|
||||||
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.1);
|
||||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||||
height: 5.5rem;
|
height: 5.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bottom-nav-item {
|
.bottom-nav-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -35,14 +32,14 @@
|
|||||||
transition: color 0.15s;
|
transition: color 0.15s;
|
||||||
position: relative;
|
position: relative;
|
||||||
min-width: 3.5rem;
|
min-width: 3.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bottom-nav-item.disabled {
|
.bottom-nav-item.disabled {
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-icon-wrap {
|
.nav-icon-wrap {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -51,9 +48,9 @@
|
|||||||
border-radius: var(--radius-full);
|
border-radius: var(--radius-full);
|
||||||
color: inherit;
|
color: inherit;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-fab {
|
.nav-fab {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -65,13 +62,13 @@
|
|||||||
box-shadow: 0 4px 14px rgba(70, 0, 173, 0.35);
|
box-shadow: 0 4px 14px rgba(70, 0, 173, 0.35);
|
||||||
margin-top: -1.5rem;
|
margin-top: -1.5rem;
|
||||||
transition: transform 0.15s;
|
transition: transform 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-fab:active {
|
.nav-fab:active {
|
||||||
transform: scale(0.92);
|
transform: scale(0.92);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-badge {
|
.nav-badge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
right: -2px;
|
right: -2px;
|
||||||
@@ -87,37 +84,36 @@
|
|||||||
border-radius: var(--radius-full);
|
border-radius: var(--radius-full);
|
||||||
border: 2px solid var(--surface-container-lowest);
|
border: 2px solid var(--surface-container-lowest);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-label {
|
.nav-label {
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
color: var(--on-surface-variant);
|
color: var(--on-surface-variant);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-label--active {
|
.nav-label--active {
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-indicator {
|
.nav-indicator {
|
||||||
width: 0.375rem;
|
width: 0.375rem;
|
||||||
height: 0.375rem;
|
height: 0.375rem;
|
||||||
background: var(--primary);
|
background: var(--primary);
|
||||||
border-radius: var(--radius-full);
|
border-radius: var(--radius-full);
|
||||||
margin-top: 0.125rem;
|
margin-top: 0.125rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bottom-nav-item.active .nav-icon-wrap {
|
.bottom-nav-item.active .nav-icon-wrap {
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bottom-nav-item.active .nav-icon-wrap svg {
|
.bottom-nav-item.active .nav-icon-wrap svg {
|
||||||
stroke-width: 2.5;
|
stroke-width: 2.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-elevated.active .nav-label {
|
.nav-elevated.active .nav-label {
|
||||||
color: var(--tertiary);
|
color: var(--tertiary);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,13 @@
|
|||||||
.top-bar {
|
.top-bar {
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.top-bar {
|
|
||||||
display: block;
|
display: block;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 40;
|
z-index: 40;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
|
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar-inner {
|
.topbar-inner {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -20,9 +15,9 @@
|
|||||||
padding: 0 var(--margin-main);
|
padding: 0 var(--margin-main);
|
||||||
max-width: 48rem;
|
max-width: 48rem;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar-back {
|
.topbar-back {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -34,30 +29,29 @@
|
|||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar-back:hover {
|
.topbar-back:hover {
|
||||||
background: var(--surface-container-high);
|
background: var(--surface-container-high);
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar-title {
|
.topbar-title {
|
||||||
font-size: 1.15rem;
|
font-size: 1.15rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar-logo-img {
|
.topbar-logo-img {
|
||||||
height: 2rem;
|
height: 2rem;
|
||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar-spacer {
|
.topbar-spacer {
|
||||||
width: 2.5rem;
|
width: 2.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar-right {
|
.topbar-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
APP_NAME=PIP - Pharmacy Integration Platform
|
||||||
|
APP_VERSION=0.1.0
|
||||||
|
DEBUG=false
|
||||||
|
|
||||||
|
DATABASE_URL=postgresql+asyncpg://pip:pip-secret@localhost:5432/pip
|
||||||
|
DATABASE_POOL_SIZE=20
|
||||||
|
DATABASE_MAX_OVERFLOW=10
|
||||||
|
DATABASE_POOL_RECYCLE=3600
|
||||||
|
|
||||||
|
REDIS_URL=redis://localhost:6379/0
|
||||||
|
REDIS_CACHE_TTL=300
|
||||||
|
|
||||||
|
RABBITMQ_URL=amqp://pip:pip-secret@localhost:5672/pip
|
||||||
|
|
||||||
|
JWT_SECRET_KEY=change-me-in-production
|
||||||
|
JWT_ALGORITHM=HS256
|
||||||
|
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||||
|
JWT_REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||||
|
JWT_ISSUER=pip-platform
|
||||||
|
|
||||||
|
RATE_LIMIT_PER_MINUTE=60
|
||||||
|
RATE_LIMIT_AUTH_PER_MINUTE=10
|
||||||
|
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
|
||||||
|
OTEL_SERVICE_NAME=pip-platform
|
||||||
|
OTEL_TRACES_ENABLED=true
|
||||||
|
OTEL_METRICS_ENABLED=true
|
||||||
|
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
LOG_JSON_FORMAT=true
|
||||||
|
|
||||||
|
CORS_ORIGINS=["*"]
|
||||||
|
CORS_ALLOW_CREDENTIALS=true
|
||||||
|
|
||||||
|
PG_PASSWORD=pip-secret
|
||||||
|
RABBITMQ_PASSWORD=pip-secret
|
||||||
|
GRAFANA_PASSWORD=admin
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
*.egg-info/
|
||||||
|
.eggs/
|
||||||
|
*.egg
|
||||||
|
.env
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
*.sqlite3
|
||||||
|
.alembic/versions/*.pyc
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
FROM python:3.13-slim AS base
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
build-essential \
|
||||||
|
libpq-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY pyproject.toml ./
|
||||||
|
RUN pip install --no-cache-dir --upgrade pip && \
|
||||||
|
pip install --no-cache-dir .
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4", "--loop", "uvloop", "--http", "httptools"]
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# PIP Platform — State Tracker
|
||||||
|
|
||||||
|
## Current Phase: 3 (Complete)
|
||||||
|
|
||||||
|
### Phase 1 — Foundation ✅
|
||||||
|
- FastAPI app skeleton, Settings, Docker Compose (Postgres, Redis, RabbitMQ)
|
||||||
|
- SQLAlchemy async models + Alembic migration `001_initial`
|
||||||
|
- Auth: JWT (access/refresh), RBAC (admin/operator/viewer), user CRUD
|
||||||
|
- Entities: Pharmacy, Connector, Medication, Inventory, Reservation, ERP System
|
||||||
|
- Repositories + Services for all entities
|
||||||
|
- API v1 endpoints for all entities
|
||||||
|
- Middleware: CORS, request logging, metrics
|
||||||
|
- Observability: OpenTelemetry traces, Prometheus metrics
|
||||||
|
|
||||||
|
### Phase 2 — Connector SDK ✅
|
||||||
|
- Abstract connector interface + Mock connector implementation
|
||||||
|
- Connector registry, version management, config CRUD
|
||||||
|
- Medicationcatalog + inventory sync plumbing
|
||||||
|
|
||||||
|
### Phase 3 — Sync, Audit, API Keys, Rate Limiting ✅
|
||||||
|
- **Sync Jobs**: Repository, service, API endpoints (create, list, get, trigger, complete, fail, cancel, logs, status filter)
|
||||||
|
- **Audit Logging**: AuditService (`log_action`, `list_logs`), AuditMiddleware (auto-logs POST/PUT/PATCH/DELETE), audit endpoint
|
||||||
|
- **API Key Auth**: Dual auth via `get_authenticated_user` — supports both Bearer JWT and X-API-Key header; `require_role` and `require_scope` use it; API Key CRUD endpoints
|
||||||
|
- **Rate Limiting**: Custom `RateLimitMiddleware` (sliding window, X-RateLimit-* headers, 429 with Retry-After); exempt paths (docs, metrics, health)
|
||||||
|
- **RabbitMQ**: Routing keys for `sync_created`, `sync_triggered`, `sync_completed`, `sync_failed`, `sync_cancelled`
|
||||||
|
|
||||||
|
## Architecture Decisions
|
||||||
|
- Clean Architecture: domain entities → services → API endpoints → infrastructure
|
||||||
|
- Repository pattern with SQLAlchemy async
|
||||||
|
- Dual auth: JWT Bearer + X-API-Key in single `get_current_user` dependency
|
||||||
|
- Audit middleware uses separate DB session (via `async_session_factory`) to decouple from request session
|
||||||
|
- Rate limiting: custom middleware replacing slowapi (incompatible with FastAPI 0.138+ `_IncludedRouter`)
|
||||||
|
- DB migrations: Alembic `upgrade head` runs in app lifespan (not `create_all`)
|
||||||
|
- `APIKeyEntity.key_hash` is `bytes` (sha256 digest), ORM model uses `LargeBinary(64)`
|
||||||
|
|
||||||
|
## Key Files
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `src/main.py` | App factory, lifespan (alembic upgrade), middleware chain |
|
||||||
|
| `src/api/v1/dependencies/auth.py` | Dual auth: JWT + API Key, require_role, require_scope |
|
||||||
|
| `src/api/middleware/audit.py` | Auto-audit for write endpoints |
|
||||||
|
| `src/api/middleware/rate_limit.py` | Custom sliding window rate limiter |
|
||||||
|
| `src/api/v1/endpoints/sync_jobs.py` | Sync job CRUD + lifecycle |
|
||||||
|
| `src/api/v1/endpoints/api_clients.py` | API client + key management |
|
||||||
|
| `src/api/v1/endpoints/audit.py` | Audit log query endpoint |
|
||||||
|
| `src/services/synchronization/sync_service.py` | Sync job business logic |
|
||||||
|
| `src/services/audit/audit_service.py` | Audit service |
|
||||||
|
| `src/infrastructure/messaging/rabbitmq.py` | Event publishing routing keys |
|
||||||
|
| `alembic/versions/001_initial.py` | Full schema (all tables including sync_jobs, sync_logs, audit_logs) |
|
||||||
|
|
||||||
|
## Configuration (env)
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `RATE_LIMIT_PER_MINUTE` | 60 | Max requests per client per minute |
|
||||||
|
| `RATE_LIMIT_AUTH_PER_MINUTE` | 10 | (reserved) Auth endpoint limit |
|
||||||
|
| `JWT_SECRET_KEY` | change-me-in-production | Must be set in production |
|
||||||
|
| `API_KEY_PREFIX` | pip | (reserved) Key namespace prefix |
|
||||||
|
|
||||||
|
## Next Phase Ideas
|
||||||
|
- Redis-backed rate limiter (for multi-instance)
|
||||||
|
- Per-client rate limits (use `api_clients.rate_limit` column)
|
||||||
|
- API Key scope enforcement on specific endpoints (currently role-only)
|
||||||
|
- Sync job execution worker (consume RabbitMQ events, run connector)
|
||||||
|
- IP allowlist enforcement for API clients
|
||||||
|
- Alembic migration 002 for any schema changes
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
[alembic]
|
||||||
|
script_location = alembic
|
||||||
|
prepend_sys_path = .
|
||||||
|
sqlalchemy.url = postgresql+psycopg2://pip:pip-secret@localhost:5432/pip
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
|
||||||
|
[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
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from sqlalchemy import engine_from_config, pool
|
||||||
|
|
||||||
|
from src.infrastructure.config.settings import settings
|
||||||
|
from src.infrastructure.database.models import Base
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL_SYNC)
|
||||||
|
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(url=url, target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"})
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
connectable = engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
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():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""${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: 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,306 @@
|
|||||||
|
"""Phase 1 initial schema
|
||||||
|
|
||||||
|
Revision ID: 001_initial
|
||||||
|
Revises: None
|
||||||
|
Create Date: 2026-06-30
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||||
|
|
||||||
|
revision: str = "001_initial"
|
||||||
|
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:
|
||||||
|
op.create_table(
|
||||||
|
"erp_systems",
|
||||||
|
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("name", sa.String(255), nullable=False),
|
||||||
|
sa.Column("erp_type", sa.String(50), nullable=False, index=True),
|
||||||
|
sa.Column("version", sa.String(50)),
|
||||||
|
sa.Column("base_url", sa.String(500)),
|
||||||
|
sa.Column("auth_config", JSONB),
|
||||||
|
sa.Column("status", sa.String(20), default="pending", index=True),
|
||||||
|
sa.Column("metadata", JSONB),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"pharmacies",
|
||||||
|
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("code", sa.String(50), unique=True, nullable=False, index=True),
|
||||||
|
sa.Column("name", sa.String(255), nullable=False),
|
||||||
|
sa.Column("address", sa.String(500)),
|
||||||
|
sa.Column("city", sa.String(100)),
|
||||||
|
sa.Column("province", sa.String(100)),
|
||||||
|
sa.Column("postal_code", sa.String(20)),
|
||||||
|
sa.Column("country", sa.String(2), default="ES"),
|
||||||
|
sa.Column("latitude", sa.Float),
|
||||||
|
sa.Column("longitude", sa.Float),
|
||||||
|
sa.Column("phone", sa.String(50)),
|
||||||
|
sa.Column("email", sa.String(255)),
|
||||||
|
sa.Column("opening_hours", JSONB),
|
||||||
|
sa.Column("status", sa.String(20), default="pending", index=True),
|
||||||
|
sa.Column("erp_type", sa.String(50)),
|
||||||
|
sa.Column("erp_version", sa.String(50)),
|
||||||
|
sa.Column("sync_strategy", sa.String(20), default="incremental"),
|
||||||
|
sa.Column("sync_interval_minutes", sa.Integer, default=30),
|
||||||
|
sa.Column("last_sync_at", sa.DateTime(timezone=True)),
|
||||||
|
sa.Column("last_sync_status", sa.String(20)),
|
||||||
|
sa.Column("metadata", JSONB),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index("ix_pharmacies_location", "pharmacies", ["latitude", "longitude"])
|
||||||
|
op.create_index("ix_pharmacies_status_erp", "pharmacies", ["status", "erp_type"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"connectors",
|
||||||
|
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("erp_system_id", UUID(as_uuid=True), sa.ForeignKey("erp_systems.id"), nullable=False, index=True),
|
||||||
|
sa.Column("pharmacy_id", UUID(as_uuid=True), sa.ForeignKey("pharmacies.id"), nullable=False, index=True),
|
||||||
|
sa.Column("name", sa.String(255), nullable=False),
|
||||||
|
sa.Column("connector_type", sa.String(50), nullable=False, index=True),
|
||||||
|
sa.Column("version", sa.String(20), default="1.0.0"),
|
||||||
|
sa.Column("config", JSONB),
|
||||||
|
sa.Column("status", sa.String(20), default="pending", index=True),
|
||||||
|
sa.Column("last_heartbeat_at", sa.DateTime(timezone=True)),
|
||||||
|
sa.Column("last_error", sa.Text),
|
||||||
|
sa.Column("metadata", JSONB),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
|
||||||
|
sa.UniqueConstraint("erp_system_id", "pharmacy_id", name="uq_connector_erp_pharmacy"),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.add_column("pharmacies", sa.Column("connector_id", UUID(as_uuid=True), sa.ForeignKey("connectors.id"), nullable=True))
|
||||||
|
op.create_index("ix_pharmacies_connector_id", "pharmacies", ["connector_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"connector_versions",
|
||||||
|
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("connector_id", UUID(as_uuid=True), sa.ForeignKey("connectors.id"), nullable=False, index=True),
|
||||||
|
sa.Column("connector_type", sa.String(50), nullable=False, index=True),
|
||||||
|
sa.Column("version", sa.String(20), nullable=False),
|
||||||
|
sa.Column("changelog", sa.Text),
|
||||||
|
sa.Column("download_url", sa.String(500)),
|
||||||
|
sa.Column("checksum", sa.String(128)),
|
||||||
|
sa.Column("is_latest", sa.Boolean, default=False, index=True),
|
||||||
|
sa.Column("is_required", sa.Boolean, default=False),
|
||||||
|
sa.Column("released_at", sa.DateTime(timezone=True)),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
|
||||||
|
sa.UniqueConstraint("connector_type", "version", name="uq_connector_version"),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"connector_configs",
|
||||||
|
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("connector_id", UUID(as_uuid=True), sa.ForeignKey("connectors.id"), nullable=False, index=True),
|
||||||
|
sa.Column("key", sa.String(100), nullable=False),
|
||||||
|
sa.Column("value", sa.Text, nullable=False),
|
||||||
|
sa.Column("encrypted", sa.Boolean, default=False),
|
||||||
|
sa.Column("description", sa.String(500)),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
|
||||||
|
sa.UniqueConstraint("connector_id", "key", name="uq_connector_config_key"),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"medications",
|
||||||
|
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("nregistro", sa.String(50), unique=True, nullable=False, index=True),
|
||||||
|
sa.Column("name", sa.String(500), nullable=False, index=True),
|
||||||
|
sa.Column("active_ingredient", sa.String(500)),
|
||||||
|
sa.Column("dosage", sa.String(100)),
|
||||||
|
sa.Column("form", sa.String(100)),
|
||||||
|
sa.Column("atc_code", sa.String(20), index=True),
|
||||||
|
sa.Column("prescription_required", sa.Boolean, default=False),
|
||||||
|
sa.Column("manufacturer", sa.String(255)),
|
||||||
|
sa.Column("metadata", JSONB),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"medication_catalog",
|
||||||
|
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("pharmacy_id", UUID(as_uuid=True), sa.ForeignKey("pharmacies.id"), nullable=False, index=True),
|
||||||
|
sa.Column("medication_id", UUID(as_uuid=True), sa.ForeignKey("medications.id"), nullable=False, index=True),
|
||||||
|
sa.Column("price", sa.Float),
|
||||||
|
sa.Column("stock", sa.Integer, default=0),
|
||||||
|
sa.Column("is_available", sa.Boolean, default=False, index=True),
|
||||||
|
sa.Column("last_verified_at", sa.DateTime(timezone=True)),
|
||||||
|
sa.Column("metadata", JSONB),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
|
||||||
|
sa.UniqueConstraint("pharmacy_id", "medication_id", name="uq_catalog_pharmacy_medication"),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"inventory_records",
|
||||||
|
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("pharmacy_id", UUID(as_uuid=True), sa.ForeignKey("pharmacies.id"), nullable=False, index=True),
|
||||||
|
sa.Column("medication_id", UUID(as_uuid=True), sa.ForeignKey("medications.id"), nullable=False, index=True),
|
||||||
|
sa.Column("stock", sa.Integer, default=0),
|
||||||
|
sa.Column("reserved_stock", sa.Integer, default=0),
|
||||||
|
sa.Column("available_stock", sa.Integer, default=0),
|
||||||
|
sa.Column("min_stock", sa.Integer),
|
||||||
|
sa.Column("max_stock", sa.Integer),
|
||||||
|
sa.Column("last_updated_at", sa.DateTime(timezone=True), index=True),
|
||||||
|
sa.Column("source", sa.String(100)),
|
||||||
|
sa.Column("batch_id", sa.String(100)),
|
||||||
|
sa.Column("metadata", JSONB),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
|
||||||
|
sa.UniqueConstraint("pharmacy_id", "medication_id", name="uq_inventory_pharmacy_medication"),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"inventory_history",
|
||||||
|
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("inventory_record_id", UUID(as_uuid=True), sa.ForeignKey("inventory_records.id"), nullable=False, index=True),
|
||||||
|
sa.Column("pharmacy_id", UUID(as_uuid=True), sa.ForeignKey("pharmacies.id"), nullable=False, index=True),
|
||||||
|
sa.Column("medication_id", UUID(as_uuid=True), sa.ForeignKey("medications.id"), nullable=False, index=True),
|
||||||
|
sa.Column("previous_stock", sa.Integer, nullable=False),
|
||||||
|
sa.Column("new_stock", sa.Integer, nullable=False),
|
||||||
|
sa.Column("delta", sa.Integer, nullable=False),
|
||||||
|
sa.Column("reason", sa.String(255)),
|
||||||
|
sa.Column("source", sa.String(100)),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False, index=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"technical_users",
|
||||||
|
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("username", sa.String(100), unique=True, nullable=False, index=True),
|
||||||
|
sa.Column("email", sa.String(255)),
|
||||||
|
sa.Column("full_name", sa.String(255)),
|
||||||
|
sa.Column("password_hash", sa.String(255), nullable=False),
|
||||||
|
sa.Column("role", sa.String(20), default="viewer", index=True),
|
||||||
|
sa.Column("is_active", sa.Boolean, default=True, index=True),
|
||||||
|
sa.Column("scopes", JSONB, default=list),
|
||||||
|
sa.Column("last_login_at", sa.DateTime(timezone=True)),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"api_clients",
|
||||||
|
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("name", sa.String(255), nullable=False),
|
||||||
|
sa.Column("description", sa.Text),
|
||||||
|
sa.Column("owner_id", UUID(as_uuid=True), sa.ForeignKey("technical_users.id"), nullable=True, index=True),
|
||||||
|
sa.Column("is_active", sa.Boolean, default=True, index=True),
|
||||||
|
sa.Column("scopes", JSONB, default=list),
|
||||||
|
sa.Column("rate_limit", sa.Integer),
|
||||||
|
sa.Column("allowed_ips", JSONB, default=list),
|
||||||
|
sa.Column("metadata", JSONB),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"api_keys",
|
||||||
|
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("api_client_id", UUID(as_uuid=True), sa.ForeignKey("api_clients.id"), nullable=False, index=True),
|
||||||
|
sa.Column("key_prefix", sa.String(10), nullable=False, index=True),
|
||||||
|
sa.Column("key_hash", sa.LargeBinary(64), nullable=False),
|
||||||
|
sa.Column("name", sa.String(255), nullable=False),
|
||||||
|
sa.Column("is_active", sa.Boolean, default=True, index=True),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True)),
|
||||||
|
sa.Column("last_used_at", sa.DateTime(timezone=True)),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index("ix_api_keys_prefix_active", "api_keys", ["key_prefix", "is_active"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"reservations",
|
||||||
|
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("pharmacy_id", UUID(as_uuid=True), sa.ForeignKey("pharmacies.id"), nullable=False, index=True),
|
||||||
|
sa.Column("medication_id", UUID(as_uuid=True), sa.ForeignKey("medications.id"), nullable=False, index=True),
|
||||||
|
sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("technical_users.id"), nullable=True, index=True),
|
||||||
|
sa.Column("external_reference", sa.String(255)),
|
||||||
|
sa.Column("quantity", sa.Integer, nullable=False),
|
||||||
|
sa.Column("status", sa.String(20), default="pending", index=True),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), index=True),
|
||||||
|
sa.Column("confirmed_at", sa.DateTime(timezone=True)),
|
||||||
|
sa.Column("cancelled_at", sa.DateTime(timezone=True)),
|
||||||
|
sa.Column("idempotency_key", sa.String(255), unique=True, index=True),
|
||||||
|
sa.Column("metadata", JSONB),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index("ix_reservations_pharmacy_status", "reservations", ["pharmacy_id", "status"])
|
||||||
|
op.create_index("ix_reservations_expires", "reservations", ["expires_at"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"sync_jobs",
|
||||||
|
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("pharmacy_id", UUID(as_uuid=True), sa.ForeignKey("pharmacies.id"), nullable=False, index=True),
|
||||||
|
sa.Column("connector_id", UUID(as_uuid=True), sa.ForeignKey("connectors.id"), nullable=False, index=True),
|
||||||
|
sa.Column("strategy", sa.String(20), nullable=False),
|
||||||
|
sa.Column("status", sa.String(20), default="pending", index=True),
|
||||||
|
sa.Column("started_at", sa.DateTime(timezone=True)),
|
||||||
|
sa.Column("completed_at", sa.DateTime(timezone=True)),
|
||||||
|
sa.Column("items_total", sa.Integer),
|
||||||
|
sa.Column("items_processed", sa.Integer),
|
||||||
|
sa.Column("items_failed", sa.Integer),
|
||||||
|
sa.Column("error_message", sa.Text),
|
||||||
|
sa.Column("metadata", JSONB),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"sync_logs",
|
||||||
|
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("sync_job_id", UUID(as_uuid=True), sa.ForeignKey("sync_jobs.id"), nullable=False, index=True),
|
||||||
|
sa.Column("level", sa.String(20), default="INFO", nullable=False),
|
||||||
|
sa.Column("message", sa.Text, nullable=False),
|
||||||
|
sa.Column("details", JSONB),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False, index=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"audit_logs",
|
||||||
|
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("actor_id", UUID(as_uuid=True), index=True),
|
||||||
|
sa.Column("actor_type", sa.String(50)),
|
||||||
|
sa.Column("action", sa.String(100), nullable=False, index=True),
|
||||||
|
sa.Column("resource_type", sa.String(100), nullable=False, index=True),
|
||||||
|
sa.Column("resource_id", sa.String(255), index=True),
|
||||||
|
sa.Column("old_value", JSONB),
|
||||||
|
sa.Column("new_value", JSONB),
|
||||||
|
sa.Column("ip_address", sa.String(45)),
|
||||||
|
sa.Column("user_agent", sa.String(500)),
|
||||||
|
sa.Column("request_id", sa.String(100), index=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False, index=True),
|
||||||
|
)
|
||||||
|
op.create_index("ix_audit_logs_action_resource", "audit_logs", ["action", "resource_type"])
|
||||||
|
op.create_index("ix_audit_logs_actor_created", "audit_logs", ["actor_id", "created_at"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("audit_logs")
|
||||||
|
op.drop_table("sync_logs")
|
||||||
|
op.drop_table("sync_jobs")
|
||||||
|
op.drop_table("reservations")
|
||||||
|
op.drop_table("api_keys")
|
||||||
|
op.drop_table("api_clients")
|
||||||
|
op.drop_table("technical_users")
|
||||||
|
op.drop_table("inventory_history")
|
||||||
|
op.drop_table("inventory_records")
|
||||||
|
op.drop_table("medication_catalog")
|
||||||
|
op.drop_table("medications")
|
||||||
|
op.drop_table("connector_configs")
|
||||||
|
op.drop_table("connector_versions")
|
||||||
|
op.drop_column("pharmacies", "connector_id")
|
||||||
|
op.drop_table("connectors")
|
||||||
|
op.drop_table("pharmacies")
|
||||||
|
op.drop_table("erp_systems")
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
services:
|
||||||
|
pip-api:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql+asyncpg://pip:${PG_PASSWORD:-pip-secret}@postgres:5432/pip
|
||||||
|
REDIS_URL: redis://redis:6379/0
|
||||||
|
RABBITMQ_URL: amqp://pip:${RABBITMQ_PASSWORD:-pip-secret}@rabbitmq:5672/pip
|
||||||
|
JWT_SECRET_KEY: ${JWT_SECRET_KEY:-change-me-in-production}
|
||||||
|
NODE_ENV: production
|
||||||
|
LOG_LEVEL: INFO
|
||||||
|
LOG_JSON_FORMAT: "true"
|
||||||
|
CORS_ORIGINS: '["*"]'
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT: http://host.docker.internal:4317
|
||||||
|
OTEL_TRACES_ENABLED: "true"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
rabbitmq:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import httpx; r = httpx.get('http://localhost:8000/api/v1/system/health'); exit(0 if r.status_code == 200 else 1)"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: "2.0"
|
||||||
|
memory: 1G
|
||||||
|
reservations:
|
||||||
|
cpus: "0.5"
|
||||||
|
memory: 256M
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: pip
|
||||||
|
POSTGRES_USER: pip
|
||||||
|
POSTGRES_PASSWORD: ${PG_PASSWORD:-pip-secret}
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U pip -d pip"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
command: >
|
||||||
|
postgres
|
||||||
|
-c max_connections=200
|
||||||
|
-c shared_buffers=256MB
|
||||||
|
-c effective_cache_size=768MB
|
||||||
|
-c work_mem=4MB
|
||||||
|
-c maintenance_work_mem=64MB
|
||||||
|
-c checkpoint_completion_target=0.9
|
||||||
|
-c wal_buffers=16MB
|
||||||
|
-c default_statistics_target=100
|
||||||
|
-c random_page_cost=1.1
|
||||||
|
-c effective_io_concurrency=200
|
||||||
|
-c max_worker_processes=8
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "6380:6379"
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
command: >
|
||||||
|
redis-server
|
||||||
|
--maxmemory 256mb
|
||||||
|
--maxmemory-policy allkeys-lru
|
||||||
|
--appendonly yes
|
||||||
|
--appendfsync everysec
|
||||||
|
|
||||||
|
rabbitmq:
|
||||||
|
image: rabbitmq:3-management-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
RABBITMQ_DEFAULT_USER: pip
|
||||||
|
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:-pip-secret}
|
||||||
|
RABBITMQ_DEFAULT_VHOST: pip
|
||||||
|
ports:
|
||||||
|
- "5672:5672"
|
||||||
|
- "15672:15672"
|
||||||
|
volumes:
|
||||||
|
- rabbitmq_data:/var/lib/rabbitmq
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
redis_data:
|
||||||
|
rabbitmq_data:
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
services:
|
||||||
|
pip-api:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql+asyncpg://pip:${PG_PASSWORD:-pip-secret}@postgres:5432/pip
|
||||||
|
REDIS_URL: redis://redis:6379/0
|
||||||
|
RABBITMQ_URL: amqp://pip:${RABBITMQ_PASSWORD:-pip-secret}@rabbitmq:5672/pip
|
||||||
|
JWT_SECRET_KEY: ${JWT_SECRET_KEY:-change-me-in-production}
|
||||||
|
NODE_ENV: production
|
||||||
|
LOG_LEVEL: INFO
|
||||||
|
LOG_JSON_FORMAT: "true"
|
||||||
|
CORS_ORIGINS: '["*"]'
|
||||||
|
# Re-routed to the shared Grafana Alloy collector on srv84-macos.
|
||||||
|
# host.docker.internal resolves to the Docker host gateway from
|
||||||
|
# inside the container — works because this stack runs on the same
|
||||||
|
# host as the monitoring stack.
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://host.docker.internal:4317}
|
||||||
|
OTEL_TRACES_ENABLED: "true"
|
||||||
|
extra_hosts:
|
||||||
|
# `host-gateway` is the standard Docker engine alias for the bridge
|
||||||
|
# gateway (host.docker.internal is mac/Windows-native; this covers bare Linux).
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
rabbitmq:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import httpx; r = httpx.get('http://localhost:8000/api/v1/system/health'); exit(0 if r.status_code == 200 else 1)"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: "2.0"
|
||||||
|
memory: 1G
|
||||||
|
reservations:
|
||||||
|
cpus: "0.5"
|
||||||
|
memory: 256M
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: pip
|
||||||
|
POSTGRES_USER: pip
|
||||||
|
POSTGRES_PASSWORD: ${PG_PASSWORD:-pip-secret}
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U pip -d pip"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
command: >
|
||||||
|
postgres
|
||||||
|
-c max_connections=200
|
||||||
|
-c shared_buffers=256MB
|
||||||
|
-c effective_cache_size=768MB
|
||||||
|
-c work_mem=4MB
|
||||||
|
-c maintenance_work_mem=64MB
|
||||||
|
-c checkpoint_completion_target=0.9
|
||||||
|
-c wal_buffers=16MB
|
||||||
|
-c default_statistics_target=100
|
||||||
|
-c random_page_cost=1.1
|
||||||
|
-c effective_io_concurrency=200
|
||||||
|
-c max_worker_processes=8
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "6380:6379"
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
command: >
|
||||||
|
redis-server
|
||||||
|
--maxmemory 256mb
|
||||||
|
--maxmemory-policy allkeys-lru
|
||||||
|
--appendonly yes
|
||||||
|
--appendfsync everysec
|
||||||
|
|
||||||
|
rabbitmq:
|
||||||
|
image: rabbitmq:3-management-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
RABBITMQ_DEFAULT_USER: pip
|
||||||
|
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:-pip-secret}
|
||||||
|
RABBITMQ_DEFAULT_VHOST: pip
|
||||||
|
ports:
|
||||||
|
- "5672:5672"
|
||||||
|
- "15672:15672"
|
||||||
|
volumes:
|
||||||
|
- rabbitmq_data:/var/lib/rabbitmq
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
redis_data:
|
||||||
|
rabbitmq_data:
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""Run Alembic migrations before starting the app.
|
||||||
|
|
||||||
|
This script is designed as a Docker entrypoint pre-migration step.
|
||||||
|
It runs `alembic upgrade head` and then starts uvicorn.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations():
|
||||||
|
db_url = os.environ.get("DATABASE_URL_SYNC") or os.environ.get("DATABASE_URL", "").replace("+asyncpg", "+psycopg2", 1)
|
||||||
|
if not db_url:
|
||||||
|
print("WARNING: No DATABASE_URL found, skipping migrations")
|
||||||
|
return
|
||||||
|
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["DATABASE_URL_SYNC"] = db_url
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
["alembic", "-c", "alembic.ini", "upgrade", "head"],
|
||||||
|
env=env,
|
||||||
|
cwd="/app",
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f"Migration failed:\n{result.stdout}\n{result.stderr}")
|
||||||
|
sys.exit(1)
|
||||||
|
print(f"Migration successful:\n{result.stdout}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run_migrations()
|
||||||
|
os.execvp(
|
||||||
|
"uvicorn",
|
||||||
|
["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000",
|
||||||
|
"--workers", "4", "--loop", "uvloop", "--http", "httptools"],
|
||||||
|
)
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Reference scrape config used by the *external* Prometheus on srv84-macos.
|
||||||
|
# This stack no longer ships its own Prometheus/Grafana — see PLAN.md and
|
||||||
|
# STATE.md for the external-integration rationale. Kept as documentation.
|
||||||
|
#
|
||||||
|
# To scrape `pip-api` directly (e.g. from a local Prometheus for debugging),
|
||||||
|
# point a static target at `host.docker.internal:8000` instead of `pip-api:8000`.
|
||||||
|
global:
|
||||||
|
scrape_interval: 15s
|
||||||
|
evaluation_interval: 15s
|
||||||
|
|
||||||
|
scrape_configs:
|
||||||
|
- job_name: "pip-platform"
|
||||||
|
metrics_path: /metrics
|
||||||
|
static_configs:
|
||||||
|
- targets: ["host.docker.internal:8000"]
|
||||||
|
labels:
|
||||||
|
host: srv84-macos
|
||||||
|
role: pip-platform
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
[project]
|
||||||
|
name = "pip-platform"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Pharmacy Integration Platform — unified API between pharmacy ERPs and external applications"
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
dependencies = [
|
||||||
|
"fastapi>=0.115.0",
|
||||||
|
"uvicorn[standard]>=0.32.0",
|
||||||
|
"sqlalchemy[asyncio]>=2.0.36",
|
||||||
|
"asyncpg>=0.30.0",
|
||||||
|
"alembic>=1.14.0",
|
||||||
|
"pydantic[email]>=2.10.0",
|
||||||
|
"pydantic-settings>=2.7.0",
|
||||||
|
"python-jose[cryptography]>=3.3.0",
|
||||||
|
"passlib[bcrypt]>=1.7.4",
|
||||||
|
"bcrypt>=4.2.0",
|
||||||
|
"python-multipart>=0.0.18",
|
||||||
|
"redis[hiredis]>=5.2.0",
|
||||||
|
"aio-pika>=9.6.0",
|
||||||
|
"opentelemetry-api>=1.29.0",
|
||||||
|
"opentelemetry-sdk>=1.29.0",
|
||||||
|
"opentelemetry-instrumentation-fastapi>=0.50b0",
|
||||||
|
"opentelemetry-instrumentation-sqlalchemy>=0.50b0",
|
||||||
|
"opentelemetry-instrumentation-redis>=0.50b0",
|
||||||
|
"opentelemetry-exporter-otlp>=1.29.0",
|
||||||
|
"prometheus-client>=0.21.0",
|
||||||
|
"structlog>=24.4.0",
|
||||||
|
"httpx>=0.28.0",
|
||||||
|
"click>=8.1.0",
|
||||||
|
"psycopg2-binary>=2.9.9",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.3.0",
|
||||||
|
"pytest-asyncio>=0.24.0",
|
||||||
|
"pytest-cov>=6.0.0",
|
||||||
|
"httpx>=0.28.0",
|
||||||
|
"ruff>=0.8.0",
|
||||||
|
"mypy>=1.13.0",
|
||||||
|
"testcontainers[postgres,redis,rabbitmq]>=4.8.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
target-version = "py313"
|
||||||
|
line-length = 120
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "I", "N", "W", "UP", "B", "SIM", "TCH", "RUF"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
testpaths = ["tests"]
|
||||||
|
addopts = "-v --tb=short"
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.13"
|
||||||
|
strict = true
|
||||||
|
plugins = ["pydantic.mypy"]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src"]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
pip = "src.cli:cli"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from .main import app
|
||||||
|
|
||||||
|
__all__ = ["app"]
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import Response
|
||||||
|
|
||||||
|
from src.core.logging import get_logger
|
||||||
|
from src.domain.entities import AuditLogEntity
|
||||||
|
from src.domain.interfaces import IAuditLogRepository
|
||||||
|
from src.infrastructure.database.session import async_session_factory
|
||||||
|
|
||||||
|
logger = get_logger("audit_middleware")
|
||||||
|
|
||||||
|
WRITE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
|
||||||
|
IGNORED_PATHS = {"/docs", "/redoc", "/openapi.json", "/metrics", "/api/v1/system/health"}
|
||||||
|
|
||||||
|
|
||||||
|
class AuditMiddleware(BaseHTTPMiddleware):
|
||||||
|
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
||||||
|
response = await call_next(request)
|
||||||
|
|
||||||
|
if request.method in WRITE_METHODS and not self._should_ignore(request.url.path):
|
||||||
|
try:
|
||||||
|
await self._record_audit(request, response)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("audit_log_failed", extra={"error": str(exc)})
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
def _should_ignore(self, path: str) -> bool:
|
||||||
|
return any(path.startswith(p) for p in IGNORED_PATHS)
|
||||||
|
|
||||||
|
async def _record_audit(self, request: Request, response: Response) -> None:
|
||||||
|
actor_id = None
|
||||||
|
actor_type = "anonymous"
|
||||||
|
|
||||||
|
if hasattr(request.state, "user") and request.state.user:
|
||||||
|
user = request.state.user
|
||||||
|
try:
|
||||||
|
actor_id = uuid.UUID(user.subject)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
actor_type = user.role.value if hasattr(user.role, "value") else str(user.role)
|
||||||
|
|
||||||
|
resource_type = self._extract_resource_type(request.url.path)
|
||||||
|
action = f"{request.method.lower()}_{resource_type}"
|
||||||
|
resource_id = self._extract_resource_id(request.url.path)
|
||||||
|
ip_address = request.client.host if request.client else None
|
||||||
|
user_agent = request.headers.get("user-agent", "")
|
||||||
|
request_id = getattr(request.state, "request_id", str(uuid.uuid4()))
|
||||||
|
|
||||||
|
async with async_session_factory() as session:
|
||||||
|
try:
|
||||||
|
from src.infrastructure.database.repositories import AuditLogRepository
|
||||||
|
repo = AuditLogRepository(session)
|
||||||
|
entity = AuditLogEntity(
|
||||||
|
actor_id=actor_id,
|
||||||
|
actor_type=actor_type,
|
||||||
|
action=action,
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=resource_id,
|
||||||
|
ip_address=ip_address,
|
||||||
|
user_agent=user_agent,
|
||||||
|
request_id=request_id,
|
||||||
|
)
|
||||||
|
await repo.add(entity)
|
||||||
|
await session.commit()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("audit_db_write_failed", extra={"error": str(exc)})
|
||||||
|
|
||||||
|
def _extract_resource_type(self, path: str) -> str:
|
||||||
|
parts = path.strip("/").split("/")
|
||||||
|
if len(parts) >= 3:
|
||||||
|
return parts[2]
|
||||||
|
if len(parts) >= 1:
|
||||||
|
return parts[-1]
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
def _extract_resource_id(self, path: str) -> str | None:
|
||||||
|
parts = path.strip("/").split("/")
|
||||||
|
for part in reversed(parts[3:] if len(parts) > 3 else parts):
|
||||||
|
try:
|
||||||
|
uuid.UUID(part)
|
||||||
|
return part
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import Response
|
||||||
|
|
||||||
|
from src.core.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger("request_logging")
|
||||||
|
|
||||||
|
|
||||||
|
class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
||||||
|
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
||||||
|
request_id = request.headers.get("X-Request-ID", str(uuid.uuid4()))
|
||||||
|
request.state.request_id = request_id
|
||||||
|
|
||||||
|
start = time.perf_counter()
|
||||||
|
response = await call_next(request)
|
||||||
|
duration_ms = (time.perf_counter() - start) * 1000
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"request_completed",
|
||||||
|
extra={
|
||||||
|
"request_id": request_id,
|
||||||
|
"method": request.method,
|
||||||
|
"path": request.url.path,
|
||||||
|
"status_code": response.status_code,
|
||||||
|
"duration_ms": round(duration_ms, 2),
|
||||||
|
"client_ip": request.client.host if request.client else None,
|
||||||
|
"user_agent": request.headers.get("user-agent", ""),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
response.headers["X-Request-ID"] = request_id
|
||||||
|
return response
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import time
|
||||||
|
|
||||||
|
from fastapi import Request, Response
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
||||||
|
|
||||||
|
from src.infrastructure.config.settings import settings
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||||
|
def __init__(self, app, requests_per_minute: int | None = None):
|
||||||
|
super().__init__(app)
|
||||||
|
self._rpm = requests_per_minute or settings.RATE_LIMIT_PER_MINUTE
|
||||||
|
self._clients: dict[str, list[float]] = {}
|
||||||
|
|
||||||
|
async def dispatch(
|
||||||
|
self, request: Request, call_next: RequestResponseEndpoint
|
||||||
|
) -> Response:
|
||||||
|
if request.method == "OPTIONS":
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
path = request.url.path
|
||||||
|
if path.startswith("/docs") or path.startswith("/redoc") or path.startswith("/openapi") or path.startswith("/metrics"):
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
client_key = self._get_client_key(request)
|
||||||
|
now = time.monotonic()
|
||||||
|
window = self._clients.setdefault(client_key, [])
|
||||||
|
window[:] = [t for t in window if now - t < 60]
|
||||||
|
|
||||||
|
if len(window) >= self._rpm:
|
||||||
|
return Response(
|
||||||
|
content='{"error":{"code":"RATE_LIMIT_EXCEEDED","message":"Rate limit exceeded. Try again later."}}',
|
||||||
|
status_code=429,
|
||||||
|
media_type="application/json",
|
||||||
|
headers={"Retry-After": "60"},
|
||||||
|
)
|
||||||
|
|
||||||
|
window.append(now)
|
||||||
|
response = await call_next(request)
|
||||||
|
remaining = self._rpm - len(window)
|
||||||
|
response.headers["X-RateLimit-Limit"] = str(self._rpm)
|
||||||
|
response.headers["X-RateLimit-Remaining"] = str(max(0, remaining))
|
||||||
|
response.headers["X-RateLimit-Reset"] = str(int(time.time()) + 60)
|
||||||
|
return response
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_client_key(request: Request) -> str:
|
||||||
|
forwarded = request.headers.get("x-forwarded-for")
|
||||||
|
if forwarded:
|
||||||
|
return forwarded.split(",")[0].strip()
|
||||||
|
if request.client:
|
||||||
|
return request.client.host
|
||||||
|
return "unknown"
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, Request, status
|
||||||
|
from fastapi.security import APIKeyHeader, HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
|
||||||
|
from src.core.exceptions import ForbiddenException, UnauthorizedException
|
||||||
|
from src.domain.entities import UserRole
|
||||||
|
from src.infrastructure.database.session import AsyncSession, get_db_session
|
||||||
|
from src.services.auth.auth_service import AuthService
|
||||||
|
from src.services.auth.security import RBACGuard, TokenData, decode_token, validate_token_type
|
||||||
|
from src.infrastructure.database.repositories import APIClientRepository, APIKeyRepository
|
||||||
|
from src.services.auth.security import verify_api_key
|
||||||
|
|
||||||
|
_bearer_scheme = HTTPBearer(auto_error=False)
|
||||||
|
_api_key_scheme = APIKeyHeader(name="X-API-Key", auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_auth_service(session: AsyncSession = Depends(get_db_session)) -> AuthService:
|
||||||
|
return AuthService(session)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_api_key_user(
|
||||||
|
api_key: str | None = Depends(_api_key_scheme),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> TokenData | None:
|
||||||
|
if not api_key:
|
||||||
|
return None
|
||||||
|
|
||||||
|
prefix = api_key[:8]
|
||||||
|
key_repo = APIKeyRepository(session)
|
||||||
|
key_entity = await key_repo.get_by_prefix(prefix)
|
||||||
|
if not key_entity or not key_entity.is_active:
|
||||||
|
return None
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
if key_entity.expires_at and key_entity.expires_at < datetime.now(timezone.utc):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not verify_api_key(api_key, key_entity.key_hash):
|
||||||
|
return None
|
||||||
|
|
||||||
|
client_repo = APIClientRepository(session)
|
||||||
|
client = await client_repo.get_by_id(key_entity.api_client_id)
|
||||||
|
if not client or not client.is_active:
|
||||||
|
return None
|
||||||
|
|
||||||
|
await key_repo.update_last_used(key_entity.id)
|
||||||
|
|
||||||
|
return TokenData({
|
||||||
|
"sub": str(client.id),
|
||||||
|
"role": "api_client",
|
||||||
|
"scopes": client.scopes or [],
|
||||||
|
"iss": "pip-platform",
|
||||||
|
"type": "access",
|
||||||
|
"jti": str(key_entity.id),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
request: Request,
|
||||||
|
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer_scheme),
|
||||||
|
api_key_user: TokenData | None = Depends(get_api_key_user),
|
||||||
|
) -> TokenData:
|
||||||
|
if credentials:
|
||||||
|
try:
|
||||||
|
payload = decode_token(credentials.credentials)
|
||||||
|
validate_token_type(payload, expected_type="access")
|
||||||
|
user = TokenData(payload)
|
||||||
|
request.state.user = user
|
||||||
|
return user
|
||||||
|
except UnauthorizedException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise UnauthorizedException(f"Invalid token: {e}") from e
|
||||||
|
|
||||||
|
if api_key_user:
|
||||||
|
request.state.user = api_key_user
|
||||||
|
return api_key_user
|
||||||
|
|
||||||
|
raise UnauthorizedException("Authentication required. Provide Bearer token or X-API-Key header.")
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user_optional(
|
||||||
|
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer_scheme),
|
||||||
|
) -> TokenData | None:
|
||||||
|
if not credentials:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
payload = decode_token(credentials.credentials)
|
||||||
|
validate_token_type(payload, expected_type="access")
|
||||||
|
return TokenData(payload)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def require_role(role: UserRole):
|
||||||
|
def _checker(current_user: TokenData = Depends(get_current_user)) -> TokenData:
|
||||||
|
RBACGuard.require_role(current_user, role)
|
||||||
|
return current_user
|
||||||
|
return _checker
|
||||||
|
|
||||||
|
|
||||||
|
def require_scope(scope: str):
|
||||||
|
def _checker(current_user: TokenData = Depends(get_current_user)) -> TokenData:
|
||||||
|
RBACGuard.require_scope(current_user, scope)
|
||||||
|
return current_user
|
||||||
|
return _checker
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from uuid import UUID
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.core.exceptions import ConflictException, NotFoundException
|
||||||
|
from src.domain.entities import APIClientEntity, APIKeyEntity
|
||||||
|
from src.infrastructure.database.repositories import APIClientRepository, APIKeyRepository
|
||||||
|
from src.infrastructure.database.session import get_db_session
|
||||||
|
from src.api.v1.dependencies.auth import get_current_user, require_role
|
||||||
|
from src.domain.entities import UserRole
|
||||||
|
from src.services.auth.security import TokenData, generate_api_key
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api-clients", tags=["API Clients"])
|
||||||
|
|
||||||
|
|
||||||
|
class APIClientCreateRequest(BaseModel):
|
||||||
|
name: str = Field(..., min_length=1, max_length=255)
|
||||||
|
description: str | None = None
|
||||||
|
scopes: list[str] = Field(default_factory=lambda: ["pharmacies:read", "medications:read", "inventory:read"])
|
||||||
|
rate_limit: int | None = None
|
||||||
|
allowed_ips: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class APIClientResponse(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
name: str
|
||||||
|
description: str | None
|
||||||
|
owner_id: UUID | None
|
||||||
|
is_active: bool
|
||||||
|
scopes: list[str]
|
||||||
|
rate_limit: int | None
|
||||||
|
allowed_ips: list[str]
|
||||||
|
created_at: datetime | None
|
||||||
|
updated_at: datetime | None
|
||||||
|
|
||||||
|
|
||||||
|
class APIKeyCreateRequest(BaseModel):
|
||||||
|
name: str = Field(..., min_length=1, max_length=255)
|
||||||
|
expires_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class APIKeyResponse(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
name: str
|
||||||
|
key_prefix: str
|
||||||
|
is_active: bool
|
||||||
|
expires_at: datetime | None
|
||||||
|
last_used_at: datetime | None
|
||||||
|
created_at: datetime | None
|
||||||
|
|
||||||
|
|
||||||
|
class APIKeyCreateResponse(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
name: str
|
||||||
|
key: str
|
||||||
|
key_prefix: str
|
||||||
|
expires_at: datetime | None
|
||||||
|
|
||||||
|
|
||||||
|
class APIClientListResponse(BaseModel):
|
||||||
|
items: list[APIClientResponse]
|
||||||
|
total: int
|
||||||
|
offset: int
|
||||||
|
limit: int
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=APIClientListResponse)
|
||||||
|
async def list_api_clients(
|
||||||
|
is_active: bool | None = None,
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
|
||||||
|
):
|
||||||
|
repo = APIClientRepository(session)
|
||||||
|
entities, total = await repo.list_clients(is_active=is_active, offset=offset, limit=limit)
|
||||||
|
items = [
|
||||||
|
APIClientResponse(
|
||||||
|
id=e.id, name=e.name, description=e.description, owner_id=e.owner_id,
|
||||||
|
is_active=e.is_active, scopes=e.scopes, rate_limit=e.rate_limit,
|
||||||
|
allowed_ips=e.allowed_ips, created_at=e.created_at, updated_at=e.updated_at,
|
||||||
|
)
|
||||||
|
for e in entities
|
||||||
|
]
|
||||||
|
return APIClientListResponse(items=items, total=total, offset=offset, limit=limit)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=APIClientResponse, status_code=201)
|
||||||
|
async def create_api_client(
|
||||||
|
body: APIClientCreateRequest,
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
|
||||||
|
):
|
||||||
|
repo = APIClientRepository(session)
|
||||||
|
entity = APIClientEntity(
|
||||||
|
name=body.name, description=body.description,
|
||||||
|
owner_id=UUID(current_user.subject), is_active=True,
|
||||||
|
scopes=body.scopes, rate_limit=body.rate_limit,
|
||||||
|
allowed_ips=body.allowed_ips,
|
||||||
|
)
|
||||||
|
created = await repo.create(entity)
|
||||||
|
return APIClientResponse(
|
||||||
|
id=created.id, name=created.name, description=created.description,
|
||||||
|
owner_id=created.owner_id, is_active=created.is_active,
|
||||||
|
scopes=created.scopes, rate_limit=created.rate_limit,
|
||||||
|
allowed_ips=created.allowed_ips, created_at=created.created_at,
|
||||||
|
updated_at=created.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{client_id}/keys", response_model=APIKeyCreateResponse, status_code=201)
|
||||||
|
async def create_api_key(
|
||||||
|
client_id: UUID,
|
||||||
|
body: APIKeyCreateRequest,
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
|
||||||
|
):
|
||||||
|
client_repo = APIClientRepository(session)
|
||||||
|
client = await client_repo.get_by_id(client_id)
|
||||||
|
if not client:
|
||||||
|
raise NotFoundException("API Client", str(client_id))
|
||||||
|
|
||||||
|
raw_key, prefix, key_hash = generate_api_key()
|
||||||
|
key_repo = APIKeyRepository(session)
|
||||||
|
key_entity = APIKeyEntity(
|
||||||
|
api_client_id=client_id, key_prefix=prefix,
|
||||||
|
key_hash=key_hash, name=body.name,
|
||||||
|
is_active=True, expires_at=body.expires_at,
|
||||||
|
)
|
||||||
|
created = await key_repo.create(key_entity)
|
||||||
|
return APIKeyCreateResponse(
|
||||||
|
id=created.id, name=created.name, key=raw_key,
|
||||||
|
key_prefix=created.key_prefix, expires_at=created.expires_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{client_id}/keys", response_model=list[APIKeyResponse])
|
||||||
|
async def list_api_keys(
|
||||||
|
client_id: UUID,
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
|
||||||
|
):
|
||||||
|
key_repo = APIKeyRepository(session)
|
||||||
|
keys = await key_repo.list_by_client(client_id)
|
||||||
|
return [
|
||||||
|
APIKeyResponse(
|
||||||
|
id=k.id, name=k.name, key_prefix=k.key_prefix,
|
||||||
|
is_active=k.is_active, expires_at=k.expires_at,
|
||||||
|
last_used_at=k.last_used_at, created_at=k.created_at,
|
||||||
|
)
|
||||||
|
for k in keys
|
||||||
|
]
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.api.v1.dependencies.auth import get_current_user, require_role
|
||||||
|
from src.domain.entities import UserRole
|
||||||
|
from src.infrastructure.database.repositories import AuditLogRepository
|
||||||
|
from src.infrastructure.database.session import get_db_session
|
||||||
|
from src.services.auth.security import TokenData
|
||||||
|
from src.services.audit.audit_service import AuditService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/audit", tags=["Audit"])
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLogResponse(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
actor_id: UUID | None
|
||||||
|
actor_type: str | None
|
||||||
|
action: str
|
||||||
|
resource_type: str
|
||||||
|
resource_id: str | None
|
||||||
|
old_value: dict | None
|
||||||
|
new_value: dict | None
|
||||||
|
ip_address: str | None
|
||||||
|
user_agent: str | None
|
||||||
|
request_id: str | None
|
||||||
|
created_at: datetime | None
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLogListResponse(BaseModel):
|
||||||
|
items: list[AuditLogResponse]
|
||||||
|
total: int
|
||||||
|
offset: int
|
||||||
|
limit: int
|
||||||
|
|
||||||
|
|
||||||
|
def _get_service(session: AsyncSession) -> AuditService:
|
||||||
|
return AuditService(AuditLogRepository(session))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/logs", response_model=AuditLogListResponse)
|
||||||
|
async def list_audit_logs(
|
||||||
|
actor_id: UUID | None = None,
|
||||||
|
action: str | None = None,
|
||||||
|
resource_type: str | None = None,
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
|
||||||
|
):
|
||||||
|
service = _get_service(session)
|
||||||
|
entities, total = await service.list_logs(
|
||||||
|
actor_id=actor_id, action=action, resource_type=resource_type,
|
||||||
|
offset=offset, limit=limit,
|
||||||
|
)
|
||||||
|
items = [
|
||||||
|
AuditLogResponse(
|
||||||
|
id=e.id, actor_id=e.actor_id, actor_type=e.actor_type,
|
||||||
|
action=e.action, resource_type=e.resource_type,
|
||||||
|
resource_id=e.resource_id, old_value=e.old_value,
|
||||||
|
new_value=e.new_value, ip_address=e.ip_address,
|
||||||
|
user_agent=e.user_agent, request_id=e.request_id,
|
||||||
|
created_at=e.created_at,
|
||||||
|
)
|
||||||
|
for e in entities
|
||||||
|
]
|
||||||
|
return AuditLogListResponse(items=items, total=total, offset=offset, limit=limit)
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from pydantic import BaseModel, EmailStr, Field
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.domain.entities import UserRole
|
||||||
|
from src.infrastructure.database.session import get_db_session
|
||||||
|
from src.services.auth.auth_service import AuthService
|
||||||
|
from src.api.v1.dependencies.auth import get_auth_service, get_current_user, require_role
|
||||||
|
from src.services.auth.security import TokenData
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth", tags=["Authentication"])
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
username: str = Field(..., min_length=3, max_length=100)
|
||||||
|
password: str = Field(..., min_length=8)
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterRequest(BaseModel):
|
||||||
|
username: str = Field(..., min_length=3, max_length=100, pattern=r"^[A-Za-z0-9_]+$")
|
||||||
|
password: str = Field(..., min_length=8)
|
||||||
|
email: EmailStr | None = None
|
||||||
|
full_name: str | None = None
|
||||||
|
role: UserRole = UserRole.VIEWER
|
||||||
|
|
||||||
|
|
||||||
|
class TokenResponse(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
refresh_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
expires_in: int
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshRequest(BaseModel):
|
||||||
|
refresh_token: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserResponse(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
username: str
|
||||||
|
email: str | None
|
||||||
|
full_name: str | None
|
||||||
|
role: str
|
||||||
|
is_active: bool
|
||||||
|
scopes: list[str]
|
||||||
|
last_login_at: datetime | None
|
||||||
|
created_at: datetime | None
|
||||||
|
|
||||||
|
|
||||||
|
class MeResponse(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
username: str
|
||||||
|
email: str | None
|
||||||
|
full_name: str | None
|
||||||
|
role: str
|
||||||
|
is_active: bool
|
||||||
|
scopes: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/register", response_model=UserResponse, status_code=201)
|
||||||
|
async def register(body: RegisterRequest, service: AuthService = Depends(get_auth_service)):
|
||||||
|
user = await service.register(
|
||||||
|
username=body.username,
|
||||||
|
password=body.password,
|
||||||
|
email=body.email,
|
||||||
|
full_name=body.full_name,
|
||||||
|
role=body.role,
|
||||||
|
)
|
||||||
|
return UserResponse(
|
||||||
|
id=user.id,
|
||||||
|
username=user.username,
|
||||||
|
email=user.email,
|
||||||
|
full_name=user.full_name,
|
||||||
|
role=user.role.value,
|
||||||
|
is_active=user.is_active,
|
||||||
|
scopes=user.scopes,
|
||||||
|
last_login_at=user.last_login_at,
|
||||||
|
created_at=user.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/token", response_model=TokenResponse)
|
||||||
|
async def login(body: LoginRequest, service: AuthService = Depends(get_auth_service)):
|
||||||
|
user, access_token, refresh_token = await service.authenticate(body.username, body.password)
|
||||||
|
return TokenResponse(
|
||||||
|
access_token=access_token,
|
||||||
|
refresh_token=refresh_token,
|
||||||
|
expires_in=30 * 60,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/refresh", response_model=TokenResponse)
|
||||||
|
async def refresh_token(body: RefreshRequest, service: AuthService = Depends(get_auth_service)):
|
||||||
|
access_token, refresh_token = await service.refresh_access_token(body.refresh_token)
|
||||||
|
return TokenResponse(
|
||||||
|
access_token=access_token,
|
||||||
|
refresh_token=refresh_token,
|
||||||
|
expires_in=30 * 60,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=MeResponse)
|
||||||
|
async def get_me(current_user: TokenData = Depends(get_current_user)):
|
||||||
|
return MeResponse(
|
||||||
|
id=UUID(current_user.subject),
|
||||||
|
username="",
|
||||||
|
email=None,
|
||||||
|
full_name=None,
|
||||||
|
role=current_user.role.value,
|
||||||
|
is_active=True,
|
||||||
|
scopes=current_user.scopes,
|
||||||
|
)
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.api.v1.dependencies.auth import get_current_user, require_role
|
||||||
|
from src.core.exceptions import NotFoundException
|
||||||
|
from src.domain.entities import MedicationCatalogEntryEntity, UserRole
|
||||||
|
from src.infrastructure.database.repositories import MedicationCatalogRepository, MedicationRepository
|
||||||
|
from src.infrastructure.database.session import get_db_session
|
||||||
|
from src.services.auth.security import TokenData
|
||||||
|
from src.services.medication.medication_service import MedicationService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/pharmacies/{pharmacy_id}/catalog", tags=["Medication Catalog"])
|
||||||
|
|
||||||
|
|
||||||
|
class CatalogEntryUpsertRequest(BaseModel):
|
||||||
|
medication_id: UUID
|
||||||
|
price: float | None = None
|
||||||
|
stock: int = 0
|
||||||
|
is_available: bool = False
|
||||||
|
last_verified_at: datetime | None = None
|
||||||
|
metadata: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CatalogEntryResponse(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
pharmacy_id: UUID
|
||||||
|
medication_id: UUID
|
||||||
|
price: float | None
|
||||||
|
stock: int
|
||||||
|
is_available: bool
|
||||||
|
last_verified_at: datetime | None
|
||||||
|
metadata: dict[str, Any] | None
|
||||||
|
created_at: datetime | None
|
||||||
|
updated_at: datetime | None
|
||||||
|
|
||||||
|
|
||||||
|
class CatalogListResponse(BaseModel):
|
||||||
|
items: list[CatalogEntryResponse]
|
||||||
|
total: int
|
||||||
|
offset: int
|
||||||
|
limit: int
|
||||||
|
|
||||||
|
|
||||||
|
def _entity_to_response(e: MedicationCatalogEntryEntity) -> CatalogEntryResponse:
|
||||||
|
return CatalogEntryResponse(
|
||||||
|
id=e.id, pharmacy_id=e.pharmacy_id, medication_id=e.medication_id,
|
||||||
|
price=e.price, stock=e.stock, is_available=e.is_available,
|
||||||
|
last_verified_at=e.last_verified_at, metadata=e.metadata_,
|
||||||
|
created_at=e.created_at, updated_at=e.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_service(session: AsyncSession) -> MedicationService:
|
||||||
|
return MedicationService(
|
||||||
|
medication_repo=MedicationRepository(session),
|
||||||
|
catalog_repo=MedicationCatalogRepository(session),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=CatalogListResponse)
|
||||||
|
async def list_catalog(
|
||||||
|
pharmacy_id: UUID,
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user: TokenData = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
service = _get_service(session)
|
||||||
|
entities, total = await service.list_catalog(pharmacy_id, offset=offset, limit=limit)
|
||||||
|
return CatalogListResponse(items=[_entity_to_response(e) for e in entities], total=total, offset=offset, limit=limit)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{medication_id}", response_model=CatalogEntryResponse)
|
||||||
|
async def get_catalog_entry(
|
||||||
|
pharmacy_id: UUID,
|
||||||
|
medication_id: UUID,
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user: TokenData = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
service = _get_service(session)
|
||||||
|
entity = await service.get_catalog_entry(pharmacy_id, medication_id)
|
||||||
|
if not entity:
|
||||||
|
raise NotFoundException("CatalogEntry", f"{pharmacy_id}/{medication_id}")
|
||||||
|
return _entity_to_response(entity)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("", response_model=CatalogEntryResponse)
|
||||||
|
async def upsert_catalog_entry(
|
||||||
|
pharmacy_id: UUID,
|
||||||
|
body: CatalogEntryUpsertRequest,
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user: TokenData = Depends(require_role(UserRole.OPERATOR)),
|
||||||
|
):
|
||||||
|
service = _get_service(session)
|
||||||
|
entity = MedicationCatalogEntryEntity(
|
||||||
|
pharmacy_id=pharmacy_id, medication_id=body.medication_id,
|
||||||
|
price=body.price, stock=body.stock, is_available=body.is_available,
|
||||||
|
last_verified_at=body.last_verified_at, metadata_=body.metadata,
|
||||||
|
)
|
||||||
|
upserted = await service.upsert_catalog_entry(entity)
|
||||||
|
return _entity_to_response(upserted)
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.api.v1.dependencies.auth import get_current_user, require_role
|
||||||
|
from src.connectors.base import ConnectorResult
|
||||||
|
from src.core.exceptions import NotFoundException, ValidationException
|
||||||
|
from src.domain.entities import ConnectorConfigEntity, ConnectorEntity, ConnectorStatus, ERPType, UserRole
|
||||||
|
from src.infrastructure.database.repositories import ConnectorConfigRepository, ConnectorRepository
|
||||||
|
from src.infrastructure.database.session import get_db_session
|
||||||
|
from src.services.auth.security import TokenData
|
||||||
|
from src.services.connector_service import ConnectorService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/connectors", tags=["Connectors"])
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectorCreateRequest(BaseModel):
|
||||||
|
erp_system_id: UUID
|
||||||
|
pharmacy_id: UUID
|
||||||
|
name: str = Field(..., min_length=1, max_length=255)
|
||||||
|
connector_type: str
|
||||||
|
version: str = "1.0.0"
|
||||||
|
config: dict[str, Any] | None = None
|
||||||
|
metadata: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectorUpdateRequest(BaseModel):
|
||||||
|
name: str | None = Field(None, min_length=1, max_length=255)
|
||||||
|
version: str | None = None
|
||||||
|
config: dict[str, Any] | None = None
|
||||||
|
status: str | None = None
|
||||||
|
metadata: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectorConfigRequest(BaseModel):
|
||||||
|
key: str = Field(..., min_length=1, max_length=100)
|
||||||
|
value: str
|
||||||
|
encrypted: bool = False
|
||||||
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectorResponse(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
erp_system_id: UUID
|
||||||
|
pharmacy_id: UUID
|
||||||
|
name: str
|
||||||
|
connector_type: str
|
||||||
|
version: str
|
||||||
|
config: dict[str, Any] | None
|
||||||
|
status: str
|
||||||
|
last_heartbeat_at: datetime | None
|
||||||
|
last_error: str | None
|
||||||
|
metadata: dict[str, Any] | None
|
||||||
|
created_at: datetime | None
|
||||||
|
updated_at: datetime | None
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectorListResponse(BaseModel):
|
||||||
|
items: list[ConnectorResponse]
|
||||||
|
total: int
|
||||||
|
offset: int
|
||||||
|
limit: int
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectorConfigResponse(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
connector_id: UUID
|
||||||
|
key: str
|
||||||
|
value: str
|
||||||
|
encrypted: bool
|
||||||
|
description: str | None
|
||||||
|
created_at: datetime | None
|
||||||
|
updated_at: datetime | None
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectorTestResponse(BaseModel):
|
||||||
|
success: bool
|
||||||
|
data: dict[str, Any] | None = None
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectorDeployResponse(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
status: str
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
def _entity_to_response(e: ConnectorEntity) -> ConnectorResponse:
|
||||||
|
return ConnectorResponse(
|
||||||
|
id=e.id, erp_system_id=e.erp_system_id, pharmacy_id=e.pharmacy_id,
|
||||||
|
name=e.name, connector_type=e.connector_type, version=e.version,
|
||||||
|
config=e.config, status=e.status, last_heartbeat_at=e.last_heartbeat_at,
|
||||||
|
last_error=e.last_error, metadata=e.metadata_, created_at=e.created_at,
|
||||||
|
updated_at=e.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _config_entity_to_response(e: ConnectorConfigEntity) -> ConnectorConfigResponse:
|
||||||
|
return ConnectorConfigResponse(
|
||||||
|
id=e.id, connector_id=e.connector_id, key=e.key, value=e.value,
|
||||||
|
encrypted=e.encrypted, description=e.description,
|
||||||
|
created_at=e.created_at, updated_at=e.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_service(session: AsyncSession) -> ConnectorService:
|
||||||
|
return ConnectorService(
|
||||||
|
connector_repo=ConnectorRepository(session),
|
||||||
|
config_repo=ConnectorConfigRepository(session),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=ConnectorListResponse)
|
||||||
|
async def list_connectors(
|
||||||
|
erp_system_id: UUID | None = None,
|
||||||
|
pharmacy_id: UUID | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user: TokenData = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
service = _get_service(session)
|
||||||
|
entities, total = await service.list_connectors(erp_system_id=erp_system_id, pharmacy_id=pharmacy_id, status=status, offset=offset, limit=limit)
|
||||||
|
return ConnectorListResponse(items=[_entity_to_response(e) for e in entities], total=total, offset=offset, limit=limit)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/types", response_model=list[str])
|
||||||
|
async def list_connector_types(
|
||||||
|
current_user: TokenData = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
from src.connectors.registry import connector_registry
|
||||||
|
return connector_registry.list_registered_types()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{connector_id}", response_model=ConnectorResponse)
|
||||||
|
async def get_connector(
|
||||||
|
connector_id: UUID,
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user: TokenData = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
service = _get_service(session)
|
||||||
|
entity = await service.get_connector(connector_id)
|
||||||
|
if not entity:
|
||||||
|
raise NotFoundException("Connector", str(connector_id))
|
||||||
|
return _entity_to_response(entity)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ConnectorResponse, status_code=201)
|
||||||
|
async def create_connector(
|
||||||
|
body: ConnectorCreateRequest,
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
|
||||||
|
):
|
||||||
|
service = _get_service(session)
|
||||||
|
entity = ConnectorEntity(
|
||||||
|
erp_system_id=body.erp_system_id, pharmacy_id=body.pharmacy_id,
|
||||||
|
name=body.name, connector_type=body.connector_type,
|
||||||
|
version=body.version, config=body.config,
|
||||||
|
status=ConnectorStatus.PENDING, metadata_=body.metadata,
|
||||||
|
)
|
||||||
|
created = await service.create_connector(entity)
|
||||||
|
return _entity_to_response(created)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{connector_id}", response_model=ConnectorResponse)
|
||||||
|
async def update_connector(
|
||||||
|
connector_id: UUID,
|
||||||
|
body: ConnectorUpdateRequest,
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
|
||||||
|
):
|
||||||
|
service = _get_service(session)
|
||||||
|
entity = await service.get_connector(connector_id)
|
||||||
|
if not entity:
|
||||||
|
raise NotFoundException("Connector", str(connector_id))
|
||||||
|
|
||||||
|
update_data = body.model_dump(exclude_unset=True)
|
||||||
|
if "metadata" in update_data:
|
||||||
|
entity.metadata_ = update_data.pop("metadata")
|
||||||
|
for key, value in update_data.items():
|
||||||
|
setattr(entity, key, value)
|
||||||
|
|
||||||
|
updated = await service.update_connector(entity)
|
||||||
|
return _entity_to_response(updated)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{connector_id}", status_code=204)
|
||||||
|
async def delete_connector(
|
||||||
|
connector_id: UUID,
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
|
||||||
|
):
|
||||||
|
service = _get_service(session)
|
||||||
|
deleted = await service.delete_connector(connector_id)
|
||||||
|
if not deleted:
|
||||||
|
raise NotFoundException("Connector", str(connector_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{connector_id}/test", response_model=ConnectorTestResponse)
|
||||||
|
async def test_connector(
|
||||||
|
connector_id: UUID,
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user: TokenData = Depends(require_role(UserRole.OPERATOR)),
|
||||||
|
):
|
||||||
|
service = _get_service(session)
|
||||||
|
result = await service.test_connection(connector_id)
|
||||||
|
return ConnectorTestResponse(success=result.success, data=result.data, error=result.error)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{connector_id}/deploy", response_model=ConnectorDeployResponse)
|
||||||
|
async def deploy_connector(
|
||||||
|
connector_id: UUID,
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
|
||||||
|
):
|
||||||
|
service = _get_service(session)
|
||||||
|
entity = await service.deploy_connector(connector_id)
|
||||||
|
return ConnectorDeployResponse(id=entity.id, status=entity.status, message="Connector deployed successfully")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{connector_id}/deactivate", response_model=ConnectorDeployResponse)
|
||||||
|
async def deactivate_connector(
|
||||||
|
connector_id: UUID,
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
|
||||||
|
):
|
||||||
|
service = _get_service(session)
|
||||||
|
entity = await service.deactivate_connector(connector_id)
|
||||||
|
return ConnectorDeployResponse(id=entity.id, status=entity.status, message="Connector deactivated successfully")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{connector_id}/configs", response_model=list[ConnectorConfigResponse])
|
||||||
|
async def list_connector_configs(
|
||||||
|
connector_id: UUID,
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user: TokenData = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
service = _get_service(session)
|
||||||
|
connector = await service.get_connector(connector_id)
|
||||||
|
if not connector:
|
||||||
|
raise NotFoundException("Connector", str(connector_id))
|
||||||
|
configs = await service.get_configs(connector_id)
|
||||||
|
return [_config_entity_to_response(c) for c in configs]
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{connector_id}/configs", response_model=ConnectorConfigResponse)
|
||||||
|
async def upsert_connector_config(
|
||||||
|
connector_id: UUID,
|
||||||
|
body: ConnectorConfigRequest,
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
|
||||||
|
):
|
||||||
|
service = _get_service(session)
|
||||||
|
connector = await service.get_connector(connector_id)
|
||||||
|
if not connector:
|
||||||
|
raise NotFoundException("Connector", str(connector_id))
|
||||||
|
cfg_entity = ConnectorConfigEntity(
|
||||||
|
connector_id=connector_id, key=body.key, value=body.value,
|
||||||
|
encrypted=body.encrypted, description=body.description,
|
||||||
|
)
|
||||||
|
created = await service.upsert_config(cfg_entity)
|
||||||
|
return _config_entity_to_response(created)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{connector_id}/configs/{config_id}", status_code=204)
|
||||||
|
async def delete_connector_config(
|
||||||
|
connector_id: UUID,
|
||||||
|
config_id: UUID,
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
|
||||||
|
):
|
||||||
|
service = _get_service(session)
|
||||||
|
await service.delete_config(config_id)
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.api.v1.dependencies.auth import get_current_user, require_role
|
||||||
|
from src.core.exceptions import NotFoundException
|
||||||
|
from src.domain.entities import ERPSystemEntity, UserRole
|
||||||
|
from src.domain.interfaces import IERPSystemRepository
|
||||||
|
from src.infrastructure.database.repositories import ERPSystemRepository
|
||||||
|
from src.infrastructure.database.session import get_db_session
|
||||||
|
from src.services.auth.security import TokenData
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/erp-systems", tags=["ERP Systems"])
|
||||||
|
|
||||||
|
|
||||||
|
class ERPSystemCreateRequest(BaseModel):
|
||||||
|
name: str = Field(..., min_length=1, max_length=255)
|
||||||
|
erp_type: str
|
||||||
|
version: str | None = None
|
||||||
|
base_url: str | None = None
|
||||||
|
auth_config: dict[str, Any] | None = None
|
||||||
|
metadata: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ERPSystemUpdateRequest(BaseModel):
|
||||||
|
name: str | None = Field(None, min_length=1, max_length=255)
|
||||||
|
version: str | None = None
|
||||||
|
base_url: str | None = None
|
||||||
|
auth_config: dict[str, Any] | None = None
|
||||||
|
status: str | None = None
|
||||||
|
metadata: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ERPSystemResponse(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
name: str
|
||||||
|
erp_type: str
|
||||||
|
version: str | None
|
||||||
|
base_url: str | None
|
||||||
|
auth_config: dict[str, Any] | None
|
||||||
|
status: str
|
||||||
|
metadata: dict[str, Any] | None
|
||||||
|
created_at: datetime | None
|
||||||
|
updated_at: datetime | None
|
||||||
|
|
||||||
|
|
||||||
|
class ERPSystemListResponse(BaseModel):
|
||||||
|
items: list[ERPSystemResponse]
|
||||||
|
total: int
|
||||||
|
offset: int
|
||||||
|
limit: int
|
||||||
|
|
||||||
|
|
||||||
|
def _entity_to_response(e: ERPSystemEntity) -> ERPSystemResponse:
|
||||||
|
return ERPSystemResponse(
|
||||||
|
id=e.id, name=e.name, erp_type=e.erp_type, version=e.version,
|
||||||
|
base_url=e.base_url, auth_config=e.auth_config, status=e.status,
|
||||||
|
metadata=e.metadata_, created_at=e.created_at, updated_at=e.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_repo(session: AsyncSession) -> IERPSystemRepository:
|
||||||
|
return ERPSystemRepository(session)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=ERPSystemListResponse)
|
||||||
|
async def list_erp_systems(
|
||||||
|
erp_type: str | None = None,
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user: TokenData = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
repo = _get_repo(session)
|
||||||
|
entities, total = await repo.list_erp_systems(erp_type=erp_type, offset=offset, limit=limit)
|
||||||
|
return ERPSystemListResponse(items=[_entity_to_response(e) for e in entities], total=total, offset=offset, limit=limit)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{erp_id}", response_model=ERPSystemResponse)
|
||||||
|
async def get_erp_system(
|
||||||
|
erp_id: UUID,
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user: TokenData = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
repo = _get_repo(session)
|
||||||
|
entity = await repo.get_by_id(erp_id)
|
||||||
|
if not entity:
|
||||||
|
raise NotFoundException("ERP System", str(erp_id))
|
||||||
|
return _entity_to_response(entity)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ERPSystemResponse, status_code=201)
|
||||||
|
async def create_erp_system(
|
||||||
|
body: ERPSystemCreateRequest,
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
|
||||||
|
):
|
||||||
|
repo = _get_repo(session)
|
||||||
|
entity = ERPSystemEntity(
|
||||||
|
name=body.name, erp_type=body.erp_type, version=body.version,
|
||||||
|
base_url=body.base_url, auth_config=body.auth_config,
|
||||||
|
metadata_=body.metadata,
|
||||||
|
)
|
||||||
|
created = await repo.create(entity)
|
||||||
|
return _entity_to_response(created)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{erp_id}", response_model=ERPSystemResponse)
|
||||||
|
async def update_erp_system(
|
||||||
|
erp_id: UUID,
|
||||||
|
body: ERPSystemUpdateRequest,
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
|
||||||
|
):
|
||||||
|
repo = _get_repo(session)
|
||||||
|
entity = await repo.get_by_id(erp_id)
|
||||||
|
if not entity:
|
||||||
|
raise NotFoundException("ERP System", str(erp_id))
|
||||||
|
|
||||||
|
update_data = body.model_dump(exclude_unset=True)
|
||||||
|
if "metadata" in update_data:
|
||||||
|
entity.metadata_ = update_data.pop("metadata")
|
||||||
|
for key, value in update_data.items():
|
||||||
|
setattr(entity, key, value)
|
||||||
|
|
||||||
|
updated = await repo.update(entity)
|
||||||
|
return _entity_to_response(updated)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{erp_id}", status_code=204)
|
||||||
|
async def delete_erp_system(
|
||||||
|
erp_id: UUID,
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
|
||||||
|
):
|
||||||
|
repo = _get_repo(session)
|
||||||
|
deleted = await repo.delete(erp_id)
|
||||||
|
if not deleted:
|
||||||
|
raise NotFoundException("ERP System", str(erp_id))
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.api.v1.dependencies.auth import get_current_user, require_role
|
||||||
|
from src.core.exceptions import NotFoundException
|
||||||
|
from src.domain.entities import InventoryRecordEntity, UserRole
|
||||||
|
from src.infrastructure.database.repositories import InventoryRepository, ReservationRepository
|
||||||
|
from src.infrastructure.database.session import get_db_session
|
||||||
|
from src.services.auth.security import TokenData
|
||||||
|
from src.services.inventory.inventory_service import InventoryService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/pharmacies/{pharmacy_id}/inventory", tags=["Inventory"])
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryUpsertRequest(BaseModel):
|
||||||
|
medication_id: UUID
|
||||||
|
stock: int = Field(..., ge=0)
|
||||||
|
reserved_stock: int = Field(0, ge=0)
|
||||||
|
available_stock: int = Field(0, ge=0)
|
||||||
|
min_stock: int | None = Field(None, ge=0)
|
||||||
|
max_stock: int | None = Field(None, ge=0)
|
||||||
|
last_updated_at: datetime | None = None
|
||||||
|
source: str | None = Field(None, max_length=100)
|
||||||
|
batch_id: str | None = Field(None, max_length=100)
|
||||||
|
metadata: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryRecordResponse(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
pharmacy_id: UUID
|
||||||
|
medication_id: UUID
|
||||||
|
stock: int
|
||||||
|
reserved_stock: int
|
||||||
|
available_stock: int
|
||||||
|
min_stock: int | None
|
||||||
|
max_stock: int | None
|
||||||
|
last_updated_at: datetime | None
|
||||||
|
source: str | None
|
||||||
|
batch_id: str | None
|
||||||
|
metadata: dict[str, Any] | None
|
||||||
|
created_at: datetime | None
|
||||||
|
updated_at: datetime | None
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryListResponse(BaseModel):
|
||||||
|
items: list[InventoryRecordResponse]
|
||||||
|
total: int
|
||||||
|
offset: int
|
||||||
|
limit: int
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryHistoryResponse(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
pharmacy_id: UUID
|
||||||
|
medication_id: UUID
|
||||||
|
previous_stock: int
|
||||||
|
new_stock: int
|
||||||
|
delta: int
|
||||||
|
reason: str | None
|
||||||
|
source: str | None
|
||||||
|
created_at: datetime | None
|
||||||
|
|
||||||
|
|
||||||
|
def _record_entity_to_response(e: InventoryRecordEntity) -> InventoryRecordResponse:
|
||||||
|
return InventoryRecordResponse(
|
||||||
|
id=e.id, pharmacy_id=e.pharmacy_id, medication_id=e.medication_id,
|
||||||
|
stock=e.stock, reserved_stock=e.reserved_stock, available_stock=e.available_stock,
|
||||||
|
min_stock=e.min_stock, max_stock=e.max_stock,
|
||||||
|
last_updated_at=e.last_updated_at, source=e.source, batch_id=e.batch_id,
|
||||||
|
metadata=e.metadata_, created_at=e.created_at, updated_at=e.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_service(session: AsyncSession) -> InventoryService:
|
||||||
|
return InventoryService(
|
||||||
|
inventory_repo=InventoryRepository(session),
|
||||||
|
reservation_repo=ReservationRepository(session),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=InventoryListResponse)
|
||||||
|
async def list_inventory(
|
||||||
|
pharmacy_id: UUID,
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user: TokenData = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
service = _get_service(session)
|
||||||
|
entities, total = await service.list_inventory(pharmacy_id, offset=offset, limit=limit)
|
||||||
|
return InventoryListResponse(items=[_record_entity_to_response(e) for e in entities], total=total, offset=offset, limit=limit)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{medication_id}", response_model=InventoryRecordResponse)
|
||||||
|
async def get_inventory_record(
|
||||||
|
pharmacy_id: UUID,
|
||||||
|
medication_id: UUID,
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user: TokenData = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
service = _get_service(session)
|
||||||
|
entity = await service.get_inventory_record(pharmacy_id, medication_id)
|
||||||
|
if not entity:
|
||||||
|
raise NotFoundException("InventoryRecord", f"{pharmacy_id}/{medication_id}")
|
||||||
|
return _record_entity_to_response(entity)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("", response_model=InventoryRecordResponse)
|
||||||
|
async def upsert_inventory_record(
|
||||||
|
pharmacy_id: UUID,
|
||||||
|
body: InventoryUpsertRequest,
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user: TokenData = Depends(require_role(UserRole.OPERATOR)),
|
||||||
|
):
|
||||||
|
service = _get_service(session)
|
||||||
|
entity = InventoryRecordEntity(
|
||||||
|
pharmacy_id=pharmacy_id, medication_id=body.medication_id,
|
||||||
|
stock=body.stock, reserved_stock=body.reserved_stock,
|
||||||
|
available_stock=body.available_stock, min_stock=body.min_stock,
|
||||||
|
max_stock=body.max_stock, last_updated_at=body.last_updated_at,
|
||||||
|
source=body.source, batch_id=body.batch_id, metadata_=body.metadata,
|
||||||
|
)
|
||||||
|
upserted = await service.upsert_inventory_record(entity)
|
||||||
|
return _record_entity_to_response(upserted)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{medication_id}/history", response_model=list[InventoryHistoryResponse])
|
||||||
|
async def get_inventory_history(
|
||||||
|
pharmacy_id: UUID,
|
||||||
|
medication_id: UUID,
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user: TokenData = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
service = _get_service(session)
|
||||||
|
history = await service.get_inventory_history(pharmacy_id, medication_id, offset=offset, limit=limit)
|
||||||
|
return [
|
||||||
|
InventoryHistoryResponse(
|
||||||
|
id=h.id, pharmacy_id=h.pharmacy_id, medication_id=h.medication_id,
|
||||||
|
previous_stock=h.previous_stock, new_stock=h.new_stock, delta=h.delta,
|
||||||
|
reason=h.reason, source=h.source, created_at=h.created_at,
|
||||||
|
)
|
||||||
|
for h in history
|
||||||
|
]
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.api.v1.dependencies.auth import get_current_user, require_role
|
||||||
|
from src.core.exceptions import ConflictException, NotFoundException
|
||||||
|
from src.domain.entities import MedicationEntity, UserRole
|
||||||
|
from src.infrastructure.database.repositories import MedicationCatalogRepository, MedicationRepository
|
||||||
|
from src.infrastructure.database.session import get_db_session
|
||||||
|
from src.services.auth.security import TokenData
|
||||||
|
from src.services.medication.medication_service import MedicationService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/medications", tags=["Medications"])
|
||||||
|
|
||||||
|
|
||||||
|
class MedicationCreateRequest(BaseModel):
|
||||||
|
nregistro: str = Field(..., min_length=1, max_length=50)
|
||||||
|
name: str = Field(..., min_length=1, max_length=500)
|
||||||
|
active_ingredient: str | None = Field(None, max_length=500)
|
||||||
|
dosage: str | None = Field(None, max_length=100)
|
||||||
|
form: str | None = Field(None, max_length=100)
|
||||||
|
atc_code: str | None = Field(None, max_length=20)
|
||||||
|
prescription_required: bool = False
|
||||||
|
manufacturer: str | None = Field(None, max_length=255)
|
||||||
|
metadata: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class MedicationUpdateRequest(BaseModel):
|
||||||
|
name: str | None = Field(None, min_length=1, max_length=500)
|
||||||
|
active_ingredient: str | None = Field(None, max_length=500)
|
||||||
|
dosage: str | None = Field(None, max_length=100)
|
||||||
|
form: str | None = Field(None, max_length=100)
|
||||||
|
atc_code: str | None = Field(None, max_length=20)
|
||||||
|
prescription_required: bool | None = None
|
||||||
|
manufacturer: str | None = Field(None, max_length=255)
|
||||||
|
metadata: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class MedicationResponse(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
nregistro: str
|
||||||
|
name: str
|
||||||
|
active_ingredient: str | None
|
||||||
|
dosage: str | None
|
||||||
|
form: str | None
|
||||||
|
atc_code: str | None
|
||||||
|
prescription_required: bool
|
||||||
|
manufacturer: str | None
|
||||||
|
metadata: dict[str, Any] | None
|
||||||
|
created_at: datetime | None
|
||||||
|
updated_at: datetime | None
|
||||||
|
|
||||||
|
|
||||||
|
class MedicationListResponse(BaseModel):
|
||||||
|
items: list[MedicationResponse]
|
||||||
|
total: int
|
||||||
|
offset: int
|
||||||
|
limit: int
|
||||||
|
|
||||||
|
|
||||||
|
def _entity_to_response(e: MedicationEntity) -> MedicationResponse:
|
||||||
|
return MedicationResponse(
|
||||||
|
id=e.id, nregistro=e.nregistro, name=e.name,
|
||||||
|
active_ingredient=e.active_ingredient, dosage=e.dosage,
|
||||||
|
form=e.form, atc_code=e.atc_code,
|
||||||
|
prescription_required=e.prescription_required,
|
||||||
|
manufacturer=e.manufacturer, metadata=e.metadata_,
|
||||||
|
created_at=e.created_at, updated_at=e.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_service(session: AsyncSession) -> MedicationService:
|
||||||
|
return MedicationService(
|
||||||
|
medication_repo=MedicationRepository(session),
|
||||||
|
catalog_repo=MedicationCatalogRepository(session),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=MedicationListResponse)
|
||||||
|
async def list_medications(
|
||||||
|
q: str | None = None,
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user: TokenData = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
service = _get_service(session)
|
||||||
|
if q:
|
||||||
|
entities, total = await service.search_medications(q, offset=offset, limit=limit)
|
||||||
|
else:
|
||||||
|
from src.domain.entities import MedicationEntity
|
||||||
|
all_entities, total = await service.search_medications("", offset=offset, limit=limit)
|
||||||
|
entities = all_entities
|
||||||
|
return MedicationListResponse(items=[_entity_to_response(e) for e in entities], total=total, offset=offset, limit=limit)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/search", response_model=MedicationListResponse)
|
||||||
|
async def search_medications(
|
||||||
|
q: str = Query(..., min_length=1),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user: TokenData = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
service = _get_service(session)
|
||||||
|
entities, total = await service.search_medications(q, offset=offset, limit=limit)
|
||||||
|
return MedicationListResponse(items=[_entity_to_response(e) for e in entities], total=total, offset=offset, limit=limit)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/nregistro/{nregistro}", response_model=MedicationResponse)
|
||||||
|
async def get_medication_by_nregistro(
|
||||||
|
nregistro: str,
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user: TokenData = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
service = _get_service(session)
|
||||||
|
entity = await service.get_by_nregistro(nregistro)
|
||||||
|
if not entity:
|
||||||
|
raise NotFoundException("Medication", nregistro)
|
||||||
|
return _entity_to_response(entity)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{medication_id}", response_model=MedicationResponse)
|
||||||
|
async def get_medication(
|
||||||
|
medication_id: UUID,
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user: TokenData = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
service = _get_service(session)
|
||||||
|
entity = await service.get_medication(medication_id)
|
||||||
|
if not entity:
|
||||||
|
raise NotFoundException("Medication", str(medication_id))
|
||||||
|
return _entity_to_response(entity)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=MedicationResponse, status_code=201)
|
||||||
|
async def create_medication(
|
||||||
|
body: MedicationCreateRequest,
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
|
||||||
|
):
|
||||||
|
service = _get_service(session)
|
||||||
|
entity = MedicationEntity(
|
||||||
|
nregistro=body.nregistro, name=body.name,
|
||||||
|
active_ingredient=body.active_ingredient, dosage=body.dosage,
|
||||||
|
form=body.form, atc_code=body.atc_code,
|
||||||
|
prescription_required=body.prescription_required,
|
||||||
|
manufacturer=body.manufacturer, metadata_=body.metadata,
|
||||||
|
)
|
||||||
|
created = await service.create_medication(entity)
|
||||||
|
return _entity_to_response(created)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{medication_id}", response_model=MedicationResponse)
|
||||||
|
async def update_medication(
|
||||||
|
medication_id: UUID,
|
||||||
|
body: MedicationUpdateRequest,
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
|
||||||
|
):
|
||||||
|
service = _get_service(session)
|
||||||
|
entity = await service.get_medication(medication_id)
|
||||||
|
if not entity:
|
||||||
|
raise NotFoundException("Medication", str(medication_id))
|
||||||
|
|
||||||
|
update_data = body.model_dump(exclude_unset=True)
|
||||||
|
if "metadata" in update_data:
|
||||||
|
entity.metadata_ = update_data.pop("metadata")
|
||||||
|
for key, value in update_data.items():
|
||||||
|
setattr(entity, key, value)
|
||||||
|
|
||||||
|
updated = await service.update_medication(entity)
|
||||||
|
return _entity_to_response(updated)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{medication_id}", status_code=204)
|
||||||
|
async def delete_medication(
|
||||||
|
medication_id: UUID,
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
|
||||||
|
):
|
||||||
|
service = _get_service(session)
|
||||||
|
deleted = await service.delete_medication(medication_id)
|
||||||
|
if not deleted:
|
||||||
|
raise NotFoundException("Medication", str(medication_id))
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.core.exceptions import NotFoundException
|
||||||
|
from src.domain.entities import PharmacyEntity, PharmacyStatus
|
||||||
|
from src.domain.interfaces import IPharmacyRepository
|
||||||
|
from src.infrastructure.database.session import get_db_session
|
||||||
|
from src.infrastructure.database.repositories import PharmacyRepository
|
||||||
|
from src.api.v1.dependencies.auth import get_current_user, require_role
|
||||||
|
from src.domain.entities import UserRole
|
||||||
|
from src.services.auth.security import TokenData
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/pharmacies", tags=["Pharmacies"])
|
||||||
|
|
||||||
|
|
||||||
|
class PharmacyCreateRequest(BaseModel):
|
||||||
|
code: str = Field(..., min_length=1, max_length=50)
|
||||||
|
name: str = Field(..., min_length=1, max_length=255)
|
||||||
|
address: str | None = None
|
||||||
|
city: str | None = None
|
||||||
|
province: str | None = None
|
||||||
|
postal_code: str | None = None
|
||||||
|
country: str = "ES"
|
||||||
|
latitude: float | None = None
|
||||||
|
longitude: float | None = None
|
||||||
|
phone: str | None = None
|
||||||
|
email: str | None = None
|
||||||
|
opening_hours: dict | None = None
|
||||||
|
erp_type: str | None = None
|
||||||
|
erp_version: str | None = None
|
||||||
|
sync_strategy: str = "incremental"
|
||||||
|
sync_interval_minutes: int = 30
|
||||||
|
|
||||||
|
|
||||||
|
class PharmacyUpdateRequest(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
address: str | None = None
|
||||||
|
city: str | None = None
|
||||||
|
province: str | None = None
|
||||||
|
postal_code: str | None = None
|
||||||
|
country: str | None = None
|
||||||
|
latitude: float | None = None
|
||||||
|
longitude: float | None = None
|
||||||
|
phone: str | None = None
|
||||||
|
email: str | None = None
|
||||||
|
opening_hours: dict | None = None
|
||||||
|
status: str | None = None
|
||||||
|
erp_type: str | None = None
|
||||||
|
erp_version: str | None = None
|
||||||
|
sync_strategy: str | None = None
|
||||||
|
sync_interval_minutes: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PharmacyResponse(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
code: str
|
||||||
|
name: str
|
||||||
|
address: str | None
|
||||||
|
city: str | None
|
||||||
|
province: str | None
|
||||||
|
postal_code: str | None
|
||||||
|
country: str
|
||||||
|
latitude: float | None
|
||||||
|
longitude: float | None
|
||||||
|
phone: str | None
|
||||||
|
email: str | None
|
||||||
|
opening_hours: dict | None
|
||||||
|
status: str
|
||||||
|
erp_type: str | None
|
||||||
|
erp_version: str | None
|
||||||
|
connector_id: UUID | None
|
||||||
|
sync_strategy: str
|
||||||
|
sync_interval_minutes: int
|
||||||
|
last_sync_at: datetime | None
|
||||||
|
last_sync_status: str | None
|
||||||
|
created_at: datetime | None
|
||||||
|
updated_at: datetime | None
|
||||||
|
|
||||||
|
|
||||||
|
class PharmacyListResponse(BaseModel):
|
||||||
|
items: list[PharmacyResponse]
|
||||||
|
total: int
|
||||||
|
offset: int
|
||||||
|
limit: int
|
||||||
|
|
||||||
|
|
||||||
|
def _entity_to_response(e: PharmacyEntity) -> PharmacyResponse:
|
||||||
|
return PharmacyResponse(
|
||||||
|
id=e.id, code=e.code, name=e.name, address=e.address, city=e.city,
|
||||||
|
province=e.province, postal_code=e.postal_code, country=e.country,
|
||||||
|
latitude=e.latitude, longitude=e.longitude, phone=e.phone, email=e.email,
|
||||||
|
opening_hours=e.opening_hours, status=e.status, erp_type=e.erp_type,
|
||||||
|
erp_version=e.erp_version, connector_id=e.connector_id,
|
||||||
|
sync_strategy=e.sync_strategy, sync_interval_minutes=e.sync_interval_minutes,
|
||||||
|
last_sync_at=e.last_sync_at, last_sync_status=e.last_sync_status,
|
||||||
|
created_at=e.created_at, updated_at=e.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_repo(session: AsyncSession) -> IPharmacyRepository:
|
||||||
|
return PharmacyRepository(session)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=PharmacyListResponse)
|
||||||
|
async def list_pharmacies(
|
||||||
|
status: str | None = None,
|
||||||
|
erp_type: str | None = None,
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user: TokenData = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
repo = _get_repo(session)
|
||||||
|
entities, total = await repo.list_pharmacies(status=status, erp_type=erp_type, offset=offset, limit=limit)
|
||||||
|
return PharmacyListResponse(items=[_entity_to_response(e) for e in entities], total=total, offset=offset, limit=limit)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{pharmacy_id}", response_model=PharmacyResponse)
|
||||||
|
async def get_pharmacy(
|
||||||
|
pharmacy_id: UUID,
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user: TokenData = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
repo = _get_repo(session)
|
||||||
|
entity = await repo.get_by_id(pharmacy_id)
|
||||||
|
if not entity:
|
||||||
|
raise NotFoundException("Pharmacy", str(pharmacy_id))
|
||||||
|
return _entity_to_response(entity)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=PharmacyResponse, status_code=201)
|
||||||
|
async def create_pharmacy(
|
||||||
|
body: PharmacyCreateRequest,
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
|
||||||
|
):
|
||||||
|
repo = _get_repo(session)
|
||||||
|
entity = PharmacyEntity(
|
||||||
|
code=body.code, name=body.name, address=body.address, city=body.city,
|
||||||
|
province=body.province, postal_code=body.postal_code, country=body.country,
|
||||||
|
latitude=body.latitude, longitude=body.longitude, phone=body.phone,
|
||||||
|
email=body.email, opening_hours=body.opening_hours,
|
||||||
|
status=PharmacyStatus.ACTIVE.value, erp_type=body.erp_type,
|
||||||
|
erp_version=body.erp_version, sync_strategy=body.sync_strategy,
|
||||||
|
sync_interval_minutes=body.sync_interval_minutes,
|
||||||
|
)
|
||||||
|
created = await repo.create(entity)
|
||||||
|
return _entity_to_response(created)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{pharmacy_id}", response_model=PharmacyResponse)
|
||||||
|
async def update_pharmacy(
|
||||||
|
pharmacy_id: UUID,
|
||||||
|
body: PharmacyUpdateRequest,
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
|
||||||
|
):
|
||||||
|
repo = _get_repo(session)
|
||||||
|
entity = await repo.get_by_id(pharmacy_id)
|
||||||
|
if not entity:
|
||||||
|
raise NotFoundException("Pharmacy", str(pharmacy_id))
|
||||||
|
|
||||||
|
update_data = body.model_dump(exclude_unset=True)
|
||||||
|
for key, value in update_data.items():
|
||||||
|
setattr(entity, key, value)
|
||||||
|
|
||||||
|
updated = await repo.update(entity)
|
||||||
|
return _entity_to_response(updated)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{pharmacy_id}", status_code=204)
|
||||||
|
async def delete_pharmacy(
|
||||||
|
pharmacy_id: UUID,
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
|
||||||
|
):
|
||||||
|
repo = _get_repo(session)
|
||||||
|
deleted = await repo.delete(pharmacy_id)
|
||||||
|
if not deleted:
|
||||||
|
raise NotFoundException("Pharmacy", str(pharmacy_id))
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.api.v1.dependencies.auth import get_current_user, require_role
|
||||||
|
from src.core.exceptions import NotFoundException, ValidationException
|
||||||
|
from src.domain.entities import ReservationEntity, ReservationStatus, UserRole
|
||||||
|
from src.infrastructure.database.repositories import InventoryRepository, ReservationRepository
|
||||||
|
from src.infrastructure.database.session import get_db_session
|
||||||
|
from src.services.auth.security import TokenData
|
||||||
|
from src.services.inventory.inventory_service import InventoryService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/pharmacies/{pharmacy_id}/reservations", tags=["Reservations"])
|
||||||
|
|
||||||
|
|
||||||
|
class ReservationCreateRequest(BaseModel):
|
||||||
|
medication_id: UUID
|
||||||
|
quantity: int = Field(..., gt=0)
|
||||||
|
external_reference: str | None = Field(None, max_length=255)
|
||||||
|
idempotency_key: str | None = Field(None, max_length=255)
|
||||||
|
ttl_minutes: int = Field(30, ge=1, le=1440)
|
||||||
|
metadata: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ReservationResponse(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
pharmacy_id: UUID
|
||||||
|
medication_id: UUID
|
||||||
|
user_id: UUID | None
|
||||||
|
external_reference: str | None
|
||||||
|
quantity: int
|
||||||
|
status: str
|
||||||
|
expires_at: datetime | None
|
||||||
|
confirmed_at: datetime | None
|
||||||
|
cancelled_at: datetime | None
|
||||||
|
idempotency_key: str | None
|
||||||
|
metadata: dict[str, Any] | None
|
||||||
|
created_at: datetime | None
|
||||||
|
updated_at: datetime | None
|
||||||
|
|
||||||
|
|
||||||
|
class ReservationListResponse(BaseModel):
|
||||||
|
items: list[ReservationResponse]
|
||||||
|
total: int
|
||||||
|
offset: int
|
||||||
|
limit: int
|
||||||
|
|
||||||
|
|
||||||
|
def _entity_to_response(e: ReservationEntity) -> ReservationResponse:
|
||||||
|
return ReservationResponse(
|
||||||
|
id=e.id, pharmacy_id=e.pharmacy_id, medication_id=e.medication_id,
|
||||||
|
user_id=e.user_id, external_reference=e.external_reference,
|
||||||
|
quantity=e.quantity, status=e.status, expires_at=e.expires_at,
|
||||||
|
confirmed_at=e.confirmed_at, cancelled_at=e.cancelled_at,
|
||||||
|
idempotency_key=e.idempotency_key, metadata=e.metadata_,
|
||||||
|
created_at=e.created_at, updated_at=e.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_service(session: AsyncSession) -> InventoryService:
|
||||||
|
return InventoryService(
|
||||||
|
inventory_repo=InventoryRepository(session),
|
||||||
|
reservation_repo=ReservationRepository(session),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=ReservationListResponse)
|
||||||
|
async def list_reservations(
|
||||||
|
pharmacy_id: UUID,
|
||||||
|
status: str | None = None,
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user: TokenData = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
service = _get_service(session)
|
||||||
|
entities, total = await service.list_reservations(pharmacy_id, status=status, offset=offset, limit=limit)
|
||||||
|
return ReservationListResponse(items=[_entity_to_response(e) for e in entities], total=total, offset=offset, limit=limit)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{reservation_id}", response_model=ReservationResponse)
|
||||||
|
async def get_reservation(
|
||||||
|
pharmacy_id: UUID,
|
||||||
|
reservation_id: UUID,
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user: TokenData = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
service = _get_service(session)
|
||||||
|
entity = await service.get_reservation(reservation_id)
|
||||||
|
if not entity or entity.pharmacy_id != pharmacy_id:
|
||||||
|
raise NotFoundException("Reservation", str(reservation_id))
|
||||||
|
return _entity_to_response(entity)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ReservationResponse, status_code=201)
|
||||||
|
async def create_reservation(
|
||||||
|
pharmacy_id: UUID,
|
||||||
|
body: ReservationCreateRequest,
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user: TokenData = Depends(require_role(UserRole.OPERATOR)),
|
||||||
|
):
|
||||||
|
service = _get_service(session)
|
||||||
|
user_id = None
|
||||||
|
if hasattr(current_user, "user_id"):
|
||||||
|
user_id = current_user.user_id
|
||||||
|
elif hasattr(current_user, "sub"):
|
||||||
|
try:
|
||||||
|
user_id = UUID(current_user.sub)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
created = await service.create_reservation(
|
||||||
|
pharmacy_id=pharmacy_id,
|
||||||
|
medication_id=body.medication_id,
|
||||||
|
quantity=body.quantity,
|
||||||
|
user_id=user_id,
|
||||||
|
external_reference=body.external_reference,
|
||||||
|
idempotency_key=body.idempotency_key,
|
||||||
|
ttl_minutes=body.ttl_minutes,
|
||||||
|
)
|
||||||
|
return _entity_to_response(created)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{reservation_id}/confirm", response_model=ReservationResponse)
|
||||||
|
async def confirm_reservation(
|
||||||
|
pharmacy_id: UUID,
|
||||||
|
reservation_id: UUID,
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user: TokenData = Depends(require_role(UserRole.OPERATOR)),
|
||||||
|
):
|
||||||
|
service = _get_service(session)
|
||||||
|
updated = await service.confirm_reservation(reservation_id)
|
||||||
|
return _entity_to_response(updated)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{reservation_id}/cancel", response_model=ReservationResponse)
|
||||||
|
async def cancel_reservation(
|
||||||
|
pharmacy_id: UUID,
|
||||||
|
reservation_id: UUID,
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user: TokenData = Depends(require_role(UserRole.OPERATOR)),
|
||||||
|
):
|
||||||
|
service = _get_service(session)
|
||||||
|
updated = await service.cancel_reservation(reservation_id)
|
||||||
|
return _entity_to_response(updated)
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.api.v1.dependencies.auth import get_current_user, require_role
|
||||||
|
from src.core.exceptions import NotFoundException
|
||||||
|
from src.domain.entities import SyncJobStatus, SyncStrategy, UserRole
|
||||||
|
from src.infrastructure.database.repositories import SyncJobRepository, SyncLogRepository
|
||||||
|
from src.infrastructure.database.session import get_db_session
|
||||||
|
from src.services.auth.security import TokenData
|
||||||
|
from src.services.synchronization.sync_service import SyncService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/pharmacies/{pharmacy_id}/sync-jobs", tags=["Sync Jobs"])
|
||||||
|
|
||||||
|
|
||||||
|
class SyncJobCreateRequest(BaseModel):
|
||||||
|
connector_id: UUID
|
||||||
|
strategy: SyncStrategy = SyncStrategy.INCREMENTAL
|
||||||
|
|
||||||
|
|
||||||
|
class SyncJobResponse(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
pharmacy_id: UUID
|
||||||
|
connector_id: UUID
|
||||||
|
strategy: str
|
||||||
|
status: str
|
||||||
|
started_at: datetime | None
|
||||||
|
completed_at: datetime | None
|
||||||
|
items_total: int | None
|
||||||
|
items_processed: int | None
|
||||||
|
items_failed: int | None
|
||||||
|
error_message: str | None
|
||||||
|
metadata: dict[str, Any] | None
|
||||||
|
created_at: datetime | None
|
||||||
|
updated_at: datetime | None
|
||||||
|
|
||||||
|
|
||||||
|
class SyncJobListResponse(BaseModel):
|
||||||
|
items: list[SyncJobResponse]
|
||||||
|
total: int
|
||||||
|
offset: int
|
||||||
|
limit: int
|
||||||
|
|
||||||
|
|
||||||
|
class SyncLogResponse(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
sync_job_id: UUID
|
||||||
|
level: str
|
||||||
|
message: str
|
||||||
|
details: dict[str, Any] | None
|
||||||
|
created_at: datetime | None
|
||||||
|
|
||||||
|
|
||||||
|
def _entity_to_response(e: Any) -> SyncJobResponse:
|
||||||
|
return SyncJobResponse(
|
||||||
|
id=e.id, pharmacy_id=e.pharmacy_id, connector_id=e.connector_id,
|
||||||
|
strategy=e.strategy, status=e.status, started_at=e.started_at,
|
||||||
|
completed_at=e.completed_at, items_total=e.items_total,
|
||||||
|
items_processed=e.items_processed, items_failed=e.items_failed,
|
||||||
|
error_message=e.error_message, metadata=e.metadata_,
|
||||||
|
created_at=e.created_at, updated_at=e.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_service(session: AsyncSession) -> SyncService:
|
||||||
|
return SyncService(
|
||||||
|
sync_job_repo=SyncJobRepository(session),
|
||||||
|
sync_log_repo=SyncLogRepository(session),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=SyncJobListResponse)
|
||||||
|
async def list_sync_jobs(
|
||||||
|
pharmacy_id: UUID,
|
||||||
|
status: str | None = None,
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user: TokenData = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
service = _get_service(session)
|
||||||
|
entities, total = await service.list_sync_jobs(pharmacy_id, status=status, offset=offset, limit=limit)
|
||||||
|
return SyncJobListResponse(items=[_entity_to_response(e) for e in entities], total=total, offset=offset, limit=limit)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{job_id}", response_model=SyncJobResponse)
|
||||||
|
async def get_sync_job(
|
||||||
|
pharmacy_id: UUID,
|
||||||
|
job_id: UUID,
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user: TokenData = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
service = _get_service(session)
|
||||||
|
entity = await service.get_sync_job(job_id)
|
||||||
|
if not entity or entity.pharmacy_id != pharmacy_id:
|
||||||
|
raise NotFoundException("SyncJob", str(job_id))
|
||||||
|
return _entity_to_response(entity)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=SyncJobResponse, status_code=201)
|
||||||
|
async def create_sync_job(
|
||||||
|
pharmacy_id: UUID,
|
||||||
|
body: SyncJobCreateRequest,
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user: TokenData = Depends(require_role(UserRole.OPERATOR)),
|
||||||
|
):
|
||||||
|
service = _get_service(session)
|
||||||
|
created = await service.create_sync_job(
|
||||||
|
pharmacy_id=pharmacy_id,
|
||||||
|
connector_id=body.connector_id,
|
||||||
|
strategy=body.strategy,
|
||||||
|
)
|
||||||
|
return _entity_to_response(created)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{job_id}/trigger", response_model=SyncJobResponse)
|
||||||
|
async def trigger_sync(
|
||||||
|
pharmacy_id: UUID,
|
||||||
|
job_id: UUID,
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user: TokenData = Depends(require_role(UserRole.OPERATOR)),
|
||||||
|
):
|
||||||
|
service = _get_service(session)
|
||||||
|
updated = await service.trigger_sync(job_id)
|
||||||
|
return _entity_to_response(updated)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{job_id}/cancel", response_model=SyncJobResponse)
|
||||||
|
async def cancel_sync(
|
||||||
|
pharmacy_id: UUID,
|
||||||
|
job_id: UUID,
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user: TokenData = Depends(require_role(UserRole.OPERATOR)),
|
||||||
|
):
|
||||||
|
service = _get_service(session)
|
||||||
|
updated = await service.cancel_sync(job_id)
|
||||||
|
return _entity_to_response(updated)
|
||||||
|
|
||||||
|
|
||||||
|
class SyncCompleteRequest(BaseModel):
|
||||||
|
items_total: int | None = None
|
||||||
|
items_processed: int | None = None
|
||||||
|
items_failed: int | None = None
|
||||||
|
duration_ms: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class SyncFailRequest(BaseModel):
|
||||||
|
error_message: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{job_id}/complete", response_model=SyncJobResponse)
|
||||||
|
async def complete_sync(
|
||||||
|
pharmacy_id: UUID,
|
||||||
|
job_id: UUID,
|
||||||
|
body: SyncCompleteRequest,
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user: TokenData = Depends(require_role(UserRole.OPERATOR)),
|
||||||
|
):
|
||||||
|
service = _get_service(session)
|
||||||
|
updated = await service.complete_sync(job_id, items_total=body.items_total, items_processed=body.items_processed, items_failed=body.items_failed)
|
||||||
|
return _entity_to_response(updated)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{job_id}/fail", response_model=SyncJobResponse)
|
||||||
|
async def fail_sync(
|
||||||
|
pharmacy_id: UUID,
|
||||||
|
job_id: UUID,
|
||||||
|
body: SyncFailRequest,
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user: TokenData = Depends(require_role(UserRole.OPERATOR)),
|
||||||
|
):
|
||||||
|
service = _get_service(session)
|
||||||
|
updated = await service.fail_sync(job_id, error_message=body.error_message)
|
||||||
|
return _entity_to_response(updated)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{job_id}/logs", response_model=list[SyncLogResponse])
|
||||||
|
async def get_sync_logs(
|
||||||
|
pharmacy_id: UUID,
|
||||||
|
job_id: UUID,
|
||||||
|
level: str | None = None,
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(100, ge=1, le=500),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user: TokenData = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
service = _get_service(session)
|
||||||
|
logs = await service.get_sync_logs(job_id, level=level, offset=offset, limit=limit)
|
||||||
|
return [
|
||||||
|
SyncLogResponse(
|
||||||
|
id=l.id, sync_job_id=l.sync_job_id, level=l.level,
|
||||||
|
message=l.message, details=l.details, created_at=l.created_at,
|
||||||
|
)
|
||||||
|
for l in logs
|
||||||
|
]
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from src.infrastructure.cache.redis_cache import redis_cache
|
||||||
|
from src.infrastructure.database.session import get_db_session
|
||||||
|
from src.infrastructure.messaging.rabbitmq import rabbitmq_client
|
||||||
|
from src.api.v1.dependencies.auth import get_current_user
|
||||||
|
from src.services.auth.security import TokenData
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/system", tags=["System"])
|
||||||
|
|
||||||
|
|
||||||
|
class HealthStatus(BaseModel):
|
||||||
|
status: str
|
||||||
|
version: str
|
||||||
|
services: dict[str, str]
|
||||||
|
|
||||||
|
|
||||||
|
class ReadinessStatus(BaseModel):
|
||||||
|
status: str
|
||||||
|
checks: dict[str, bool]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health", response_model=HealthStatus)
|
||||||
|
async def health_check():
|
||||||
|
return HealthStatus(
|
||||||
|
status="healthy",
|
||||||
|
version="0.1.0",
|
||||||
|
services={
|
||||||
|
"api": "healthy",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ready", response_model=ReadinessStatus)
|
||||||
|
async def readiness_check(db: AsyncSession = Depends(get_db_session)):
|
||||||
|
checks: dict[str, bool] = {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check database connection
|
||||||
|
await db.execute(text("SELECT 1"))
|
||||||
|
db_ok = True
|
||||||
|
except Exception:
|
||||||
|
db_ok = False
|
||||||
|
checks["database"] = db_ok
|
||||||
|
|
||||||
|
redis_ok = await redis_cache.health_check()
|
||||||
|
checks["redis"] = redis_ok
|
||||||
|
|
||||||
|
mq_ok = await rabbitmq_client.health_check()
|
||||||
|
checks["rabbitmq"] = mq_ok
|
||||||
|
|
||||||
|
overall = all(checks.values())
|
||||||
|
return ReadinessStatus(
|
||||||
|
status="ready" if overall else "degraded",
|
||||||
|
checks=checks,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
async def system_status(current_user: TokenData = Depends(get_current_user)):
|
||||||
|
return {
|
||||||
|
"platform": "Pharmacy Integration Platform",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"environment": "production",
|
||||||
|
"services": {
|
||||||
|
"auth": "active",
|
||||||
|
"pharmacy": "active",
|
||||||
|
"medication": "active",
|
||||||
|
"connector": "active",
|
||||||
|
"synchronization": "active",
|
||||||
|
"audit": "active",
|
||||||
|
"notification": "active",
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.core.exceptions import NotFoundException
|
||||||
|
from src.domain.entities import TechnicalUserEntity, UserRole
|
||||||
|
from src.domain.interfaces import ITechnicalUserRepository
|
||||||
|
from src.infrastructure.database.repositories import TechnicalUserRepository
|
||||||
|
from src.infrastructure.database.session import get_db_session
|
||||||
|
from src.api.v1.dependencies.auth import get_current_user, require_role
|
||||||
|
from src.services.auth.security import TokenData
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/users", tags=["Technical Users"])
|
||||||
|
|
||||||
|
|
||||||
|
class UserResponse(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
username: str
|
||||||
|
email: str | None
|
||||||
|
full_name: str | None
|
||||||
|
role: str
|
||||||
|
is_active: bool
|
||||||
|
scopes: list[str]
|
||||||
|
last_login_at: datetime | None
|
||||||
|
created_at: datetime | None
|
||||||
|
|
||||||
|
|
||||||
|
class UserListResponse(BaseModel):
|
||||||
|
items: list[UserResponse]
|
||||||
|
total: int
|
||||||
|
offset: int
|
||||||
|
limit: int
|
||||||
|
|
||||||
|
|
||||||
|
def _entity_to_response(e: TechnicalUserEntity) -> UserResponse:
|
||||||
|
return UserResponse(
|
||||||
|
id=e.id, username=e.username, email=e.email, full_name=e.full_name,
|
||||||
|
role=e.role, is_active=e.is_active, scopes=e.scopes,
|
||||||
|
last_login_at=e.last_login_at, created_at=e.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=UserListResponse)
|
||||||
|
async def list_users(
|
||||||
|
role: str | None = None,
|
||||||
|
is_active: bool | None = None,
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
|
||||||
|
):
|
||||||
|
repo = TechnicalUserRepository(session)
|
||||||
|
entities, total = await repo.list_users(role=role, is_active=is_active, offset=offset, limit=limit)
|
||||||
|
return UserListResponse(items=[_entity_to_response(e) for e in entities], total=total, offset=offset, limit=limit)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{user_id}", response_model=UserResponse)
|
||||||
|
async def get_user(
|
||||||
|
user_id: UUID,
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
|
||||||
|
):
|
||||||
|
repo = TechnicalUserRepository(session)
|
||||||
|
entity = await repo.get_by_id(user_id)
|
||||||
|
if not entity:
|
||||||
|
raise NotFoundException("User", str(user_id))
|
||||||
|
return _entity_to_response(entity)
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from src.api.v1.endpoints.auth import router as auth_router
|
||||||
|
from src.api.v1.endpoints.pharmacies import router as pharmacies_router
|
||||||
|
from src.api.v1.endpoints.users import router as users_router
|
||||||
|
from src.api.v1.endpoints.api_clients import router as api_clients_router
|
||||||
|
from src.api.v1.endpoints.audit import router as audit_router
|
||||||
|
from src.api.v1.endpoints.system import router as system_router
|
||||||
|
from src.api.v1.endpoints.connectors import router as connectors_router
|
||||||
|
from src.api.v1.endpoints.erp_systems import router as erp_systems_router
|
||||||
|
from src.api.v1.endpoints.medications import router as medications_router
|
||||||
|
from src.api.v1.endpoints.catalog import router as catalog_router
|
||||||
|
from src.api.v1.endpoints.inventory import router as inventory_router
|
||||||
|
from src.api.v1.endpoints.reservations import router as reservations_router
|
||||||
|
from src.api.v1.endpoints.sync_jobs import router as sync_jobs_router
|
||||||
|
|
||||||
|
v1_router = APIRouter(prefix="/api/v1")
|
||||||
|
|
||||||
|
v1_router.include_router(auth_router)
|
||||||
|
v1_router.include_router(pharmacies_router)
|
||||||
|
v1_router.include_router(users_router)
|
||||||
|
v1_router.include_router(api_clients_router)
|
||||||
|
v1_router.include_router(audit_router)
|
||||||
|
v1_router.include_router(system_router)
|
||||||
|
v1_router.include_router(connectors_router)
|
||||||
|
v1_router.include_router(erp_systems_router)
|
||||||
|
v1_router.include_router(medications_router)
|
||||||
|
v1_router.include_router(catalog_router)
|
||||||
|
v1_router.include_router(inventory_router)
|
||||||
|
v1_router.include_router(reservations_router)
|
||||||
|
v1_router.include_router(sync_jobs_router)
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"""CLI entrypoints for the PIP platform."""
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from src.core.logging import configure_logging, get_logger
|
||||||
|
from src.infrastructure.cache.redis_cache import redis_cache
|
||||||
|
from src.infrastructure.config.settings import settings
|
||||||
|
from src.infrastructure.database.session import async_session_factory, engine
|
||||||
|
from src.infrastructure.messaging.rabbitmq import rabbitmq_client
|
||||||
|
from src.services.auth.auth_service import AuthService
|
||||||
|
from src.domain.entities import UserRole
|
||||||
|
|
||||||
|
|
||||||
|
@click.group()
|
||||||
|
def cli():
|
||||||
|
"""PIP platform CLI."""
|
||||||
|
configure_logging()
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
def run():
|
||||||
|
"""Start the API server."""
|
||||||
|
import uvicorn
|
||||||
|
uvicorn.run(
|
||||||
|
"src.main:app",
|
||||||
|
host="0.0.0.0",
|
||||||
|
port=8000,
|
||||||
|
reload=True if settings.DEBUG else False,
|
||||||
|
log_level=settings.LOG_LEVEL.lower(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
def migrate_upgrade():
|
||||||
|
"""Apply database migrations."""
|
||||||
|
from alembic.config import Config
|
||||||
|
from alembic import command
|
||||||
|
|
||||||
|
cfg = Config("alembic.ini")
|
||||||
|
command.upgrade(cfg, "head")
|
||||||
|
click.echo("Migrations applied.")
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
def migrate_downgrade():
|
||||||
|
"""Roll back the last database migration."""
|
||||||
|
from alembic.config import Config
|
||||||
|
from alembic import command
|
||||||
|
|
||||||
|
cfg = Config("alembic.ini")
|
||||||
|
command.downgrade(cfg, "-1")
|
||||||
|
click.echo("Rollback complete.")
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
def migrate_revision():
|
||||||
|
"""Create a new database revision."""
|
||||||
|
from alembic.config import Config
|
||||||
|
from alembic import command
|
||||||
|
|
||||||
|
message = click.prompt("Revision message")
|
||||||
|
cfg = Config("alembic.ini")
|
||||||
|
command.revision(cfg, autogenerate=True, message=message)
|
||||||
|
click.echo("Revision created.")
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
@click.option("--username", required=True)
|
||||||
|
@click.option("--password", required=True)
|
||||||
|
@click.option("--email", default=None)
|
||||||
|
@click.option("--role", default="admin", type=click.Choice([r.value for r in UserRole]))
|
||||||
|
def create_user(username: str, password: str, email: str | None, role: str):
|
||||||
|
"""Create a technical user."""
|
||||||
|
asyncio.run(_create_user(username, password, email, role))
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_user(username: str, password: str, email: str | None, role: str):
|
||||||
|
async with async_session_factory() as session:
|
||||||
|
service = AuthService(session)
|
||||||
|
user = await service.register(
|
||||||
|
username=username,
|
||||||
|
password=password,
|
||||||
|
email=email,
|
||||||
|
role=UserRole(role),
|
||||||
|
)
|
||||||
|
click.echo(f"Created user '{user.username}' with role '{user.role.value}'.")
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
def health():
|
||||||
|
"""Check platform health across services."""
|
||||||
|
asyncio.run(_health())
|
||||||
|
|
||||||
|
|
||||||
|
async def _health():
|
||||||
|
logger = get_logger("cli.health")
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(lambda s: None)
|
||||||
|
click.echo("Database: OK")
|
||||||
|
|
||||||
|
await redis_cache.connect()
|
||||||
|
ok = await redis_cache.health_check()
|
||||||
|
click.echo(f"Redis: {'OK' if ok else 'FAIL'}")
|
||||||
|
await redis_cache.disconnect()
|
||||||
|
|
||||||
|
await rabbitmq_client.connect()
|
||||||
|
ok = await rabbitmq_client.health_check()
|
||||||
|
click.echo(f"RabbitMQ: {'OK' if ok else 'FAIL'}")
|
||||||
|
await rabbitmq_client.disconnect()
|
||||||
|
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
cli()
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from .base_connector import (
|
||||||
|
BaseInventoryConnector,
|
||||||
|
ConnectorResult,
|
||||||
|
StockInfo,
|
||||||
|
PharmacyInfo,
|
||||||
|
SyncResult,
|
||||||
|
ReservationResult,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BaseInventoryConnector",
|
||||||
|
"ConnectorResult",
|
||||||
|
"StockInfo",
|
||||||
|
"PharmacyInfo",
|
||||||
|
"SyncResult",
|
||||||
|
"ReservationResult",
|
||||||
|
]
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectorResult(BaseModel):
|
||||||
|
success: bool
|
||||||
|
data: Any | None = None
|
||||||
|
error: str | None = None
|
||||||
|
metadata: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class StockInfo(BaseModel):
|
||||||
|
medication_id: UUID
|
||||||
|
nregistro: str
|
||||||
|
name: str
|
||||||
|
stock: int
|
||||||
|
reserved_stock: int = 0
|
||||||
|
available_stock: int = 0
|
||||||
|
price: float | None = None
|
||||||
|
last_updated: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PharmacyInfo(BaseModel):
|
||||||
|
pharmacy_id: UUID
|
||||||
|
code: str
|
||||||
|
name: str
|
||||||
|
address: str | None = None
|
||||||
|
phone: str | None = None
|
||||||
|
email: str | None = None
|
||||||
|
opening_hours: dict | None = None
|
||||||
|
latitude: float | None = None
|
||||||
|
longitude: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SyncResult(BaseModel):
|
||||||
|
success: bool
|
||||||
|
items_total: int = 0
|
||||||
|
items_processed: int = 0
|
||||||
|
items_failed: int = 0
|
||||||
|
error_message: str | None = None
|
||||||
|
metadata: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ReservationResult(BaseModel):
|
||||||
|
success: bool
|
||||||
|
reservation_id: str | None = None
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BaseInventoryConnector(ABC):
|
||||||
|
connector_type: str = "base"
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_stock(self, pharmacy_id: UUID, medication_id: UUID | None = None, nregistro: str | None = None) -> ConnectorResult:
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def reserve(self, pharmacy_id: UUID, medication_id: UUID, quantity: int, idempotency_key: str | None = None) -> ConnectorResult:
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def cancel_reservation(self, pharmacy_id: UUID, reservation_id: str) -> ConnectorResult:
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def pharmacy_information(self, pharmacy_id: UUID) -> ConnectorResult:
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def synchronize(self, pharmacy_id: UUID, strategy: str = "incremental", since: str | None = None) -> ConnectorResult:
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def heartbeat(self, pharmacy_id: UUID) -> ConnectorResult:
|
||||||
|
...
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from src.connectors.farmatic.farmatic_connector import FarmaticConnector
|
||||||
|
|
||||||
|
__all__ = ["FarmaticConnector"]
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from src.connectors.sdk.base_sdk import SDKConnector
|
||||||
|
from src.connectors.sdk.context import ConnectorContext
|
||||||
|
from src.connectors.base import ConnectorResult
|
||||||
|
from src.core.exceptions import ConnectorException
|
||||||
|
from src.core.logging import get_logger
|
||||||
|
from src.core.metrics import CONNECTOR_OPERATIONS
|
||||||
|
|
||||||
|
logger = get_logger("farmatic_connector")
|
||||||
|
|
||||||
|
|
||||||
|
class FarmaticConnector(SDKConnector):
|
||||||
|
connector_type: str = "farmatic"
|
||||||
|
|
||||||
|
def configure(self, config: dict[str, Any]) -> None:
|
||||||
|
self._base_url = config.get("base_url", "https://erp.farmatic.local/api/v1")
|
||||||
|
self._timeout = config.get("timeout", 45.0)
|
||||||
|
super().configure(config)
|
||||||
|
|
||||||
|
async def get_stock(self, pharmacy_id: UUID, medication_id: UUID | None = None, nregistro: str | None = None) -> ConnectorResult:
|
||||||
|
try:
|
||||||
|
params: dict[str, Any] = {"pharmacy": str(pharmacy_id)}
|
||||||
|
if nregistro:
|
||||||
|
params["nregistro"] = nregistro
|
||||||
|
if medication_id:
|
||||||
|
params["medication_id"] = str(medication_id)
|
||||||
|
|
||||||
|
response = await self._request("GET", "/stock", params=params)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
self._record_metric("get_stock", "success")
|
||||||
|
return ConnectorResult(success=True, data=data)
|
||||||
|
except Exception as exc:
|
||||||
|
self._record_metric("get_stock", "error")
|
||||||
|
logger.error("farmatic_get_stock_error", extra={"pharmacy_id": str(pharmacy_id), "error": str(exc)})
|
||||||
|
return ConnectorResult(success=False, error=str(exc))
|
||||||
|
|
||||||
|
async def reserve(self, pharmacy_id: UUID, medication_id: UUID, quantity: int, idempotency_key: str | None = None) -> ConnectorResult:
|
||||||
|
try:
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"pharmacy_id": str(pharmacy_id),
|
||||||
|
"medication_id": str(medication_id),
|
||||||
|
"quantity": quantity,
|
||||||
|
}
|
||||||
|
if idempotency_key:
|
||||||
|
payload["idempotency_key"] = idempotency_key
|
||||||
|
|
||||||
|
response = await self._request("POST", "/reservations", json=payload)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
self._record_metric("reserve", "success")
|
||||||
|
return ConnectorResult(success=True, data=data)
|
||||||
|
except Exception as exc:
|
||||||
|
self._record_metric("reserve", "error")
|
||||||
|
logger.error("farmatic_reserve_error", extra={"pharmacy_id": str(pharmacy_id), "error": str(exc)})
|
||||||
|
return ConnectorResult(success=False, error=str(exc))
|
||||||
|
|
||||||
|
async def cancel_reservation(self, pharmacy_id: UUID, reservation_id: str) -> ConnectorResult:
|
||||||
|
try:
|
||||||
|
response = await self._request("DELETE", f"/reservations/{reservation_id}", json={"pharmacy_id": str(pharmacy_id)})
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
self._record_metric("cancel_reservation", "success")
|
||||||
|
return ConnectorResult(success=True, data=data)
|
||||||
|
except Exception as exc:
|
||||||
|
self._record_metric("cancel_reservation", "error")
|
||||||
|
logger.error("farmatic_cancel_error", extra={"reservation_id": reservation_id, "error": str(exc)})
|
||||||
|
return ConnectorResult(success=False, error=str(exc))
|
||||||
|
|
||||||
|
async def pharmacy_information(self, pharmacy_id: UUID) -> ConnectorResult:
|
||||||
|
try:
|
||||||
|
response = await self._request("GET", f"/pharmacies/{pharmacy_id}")
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
self._record_metric("pharmacy_information", "success")
|
||||||
|
return ConnectorResult(success=True, data=data)
|
||||||
|
except Exception as exc:
|
||||||
|
self._record_metric("pharmacy_information", "error")
|
||||||
|
logger.error("farmatic_pharmacy_info_error", extra={"pharmacy_id": str(pharmacy_id), "error": str(exc)})
|
||||||
|
return ConnectorResult(success=False, error=str(exc))
|
||||||
|
|
||||||
|
async def synchronize(self, pharmacy_id: UUID, strategy: str = "incremental", since: str | None = None) -> ConnectorResult:
|
||||||
|
try:
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"pharmacy_id": str(pharmacy_id),
|
||||||
|
"strategy": strategy,
|
||||||
|
}
|
||||||
|
if since:
|
||||||
|
payload["since"] = since
|
||||||
|
|
||||||
|
response = await self._request("POST", "/sync", json=payload)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
self._record_metric("synchronize", "success")
|
||||||
|
return ConnectorResult(success=True, data=data)
|
||||||
|
except Exception as exc:
|
||||||
|
self._record_metric("synchronize", "error")
|
||||||
|
logger.error("farmatic_sync_error", extra={"pharmacy_id": str(pharmacy_id), "error": str(exc)})
|
||||||
|
return ConnectorResult(success=False, error=str(exc))
|
||||||
|
|
||||||
|
async def heartbeat(self, pharmacy_id: UUID) -> ConnectorResult:
|
||||||
|
try:
|
||||||
|
response = await self._request("GET", "/health")
|
||||||
|
data = response.json()
|
||||||
|
data["connector_type"] = self.connector_type
|
||||||
|
data["pharmacy_id"] = str(pharmacy_id)
|
||||||
|
data["circuit_breaker"] = self._circuit_breaker.state.value
|
||||||
|
|
||||||
|
self._record_metric("heartbeat", "success")
|
||||||
|
return ConnectorResult(success=True, data=data)
|
||||||
|
except Exception as exc:
|
||||||
|
self._record_metric("heartbeat", "error")
|
||||||
|
return ConnectorResult(
|
||||||
|
success=False,
|
||||||
|
error=str(exc),
|
||||||
|
metadata={"circuit_breaker": self._circuit_breaker.state.value},
|
||||||
|
)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from .mock_connector import MockConnector
|
||||||
|
|
||||||
|
__all__ = ["MockConnector"]
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import asyncio
|
||||||
|
import random
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from src.connectors.base import BaseInventoryConnector, ConnectorResult
|
||||||
|
from src.core.logging import get_logger
|
||||||
|
from src.core.metrics import CONNECTOR_OPERATIONS
|
||||||
|
|
||||||
|
logger = get_logger("mock_connector")
|
||||||
|
|
||||||
|
|
||||||
|
class MockConnector(BaseInventoryConnector):
|
||||||
|
connector_type: str = "mock"
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._simulate_latency: bool = True
|
||||||
|
self._latency_range_ms: tuple[int, int] = (20, 150)
|
||||||
|
self._error_rate: float = 0.0
|
||||||
|
self._stock_data: dict[str, dict[str, Any]] = {}
|
||||||
|
self._reservations: dict[str, dict[str, Any]] = {}
|
||||||
|
self._pharmacies: dict[str, dict[str, Any]] = {}
|
||||||
|
self._init_mock_data()
|
||||||
|
|
||||||
|
def configure(self, config: dict[str, Any]) -> None:
|
||||||
|
self._simulate_latency = config.get("simulate_latency", True)
|
||||||
|
self._latency_range_ms = tuple(config.get("latency_range_ms", [20, 150]))
|
||||||
|
self._error_rate = config.get("error_rate", 0.0)
|
||||||
|
|
||||||
|
def _init_mock_data(self) -> None:
|
||||||
|
medicamentos = [
|
||||||
|
("00001", "Ibuprofeno 600mg", 100),
|
||||||
|
("00002", "Paracetamol 1g", 50),
|
||||||
|
("00003", "Amoxicilina 500mg", 200),
|
||||||
|
("00004", "Omeprazol 20mg", 75),
|
||||||
|
("00005", "Loratadina 10mg", 120),
|
||||||
|
("00006", "Diazepam 5mg", 30),
|
||||||
|
("00007", "Metformina 850mg", 250),
|
||||||
|
("00008", "Atorvastatina 20mg", 80),
|
||||||
|
("00009", "Salbutamol Inhalador", 15),
|
||||||
|
("00010", "Insulina Glargina", 40),
|
||||||
|
]
|
||||||
|
for nregistro, name, stock in medicamentos:
|
||||||
|
reserved = random.randint(0, max(stock // 10, 1))
|
||||||
|
self._stock_data[nregistro] = {
|
||||||
|
"nregistro": nregistro,
|
||||||
|
"name": name,
|
||||||
|
"stock": stock,
|
||||||
|
"reserved_stock": reserved,
|
||||||
|
"available_stock": stock - reserved,
|
||||||
|
"price": round(random.uniform(2.5, 45.0), 2),
|
||||||
|
"last_updated": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
self._pharmacies["default"] = {
|
||||||
|
"code": "FARM-001",
|
||||||
|
"name": "Farmacia Mock Central",
|
||||||
|
"address": "Calle Mayor 15, Madrid",
|
||||||
|
"phone": "+34912345678",
|
||||||
|
"email": "central@mock-farmacia.es",
|
||||||
|
"opening_hours": {
|
||||||
|
"mon_fri": ["09:00", "21:30"],
|
||||||
|
"sat": ["10:00", "14:00"],
|
||||||
|
"sun": "closed",
|
||||||
|
},
|
||||||
|
"latitude": 40.4168,
|
||||||
|
"longitude": -3.7038,
|
||||||
|
}
|
||||||
|
self._pharmacies["second"] = {
|
||||||
|
"code": "FARM-002",
|
||||||
|
"name": "Farmacia Mock Norte",
|
||||||
|
"address": "Av. de la Ilustración 1, Madrid",
|
||||||
|
"phone": "+34915432100",
|
||||||
|
"email": "norte@mock-farmacia.es",
|
||||||
|
"opening_hours": {
|
||||||
|
"mon_fri": ["08:30", "22:00"],
|
||||||
|
"sat": ["09:00", "22:00"],
|
||||||
|
"sun": ["10:00", "14:00"],
|
||||||
|
},
|
||||||
|
"latitude": 40.4722,
|
||||||
|
"longitude": -3.6934,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _maybe_delay(self) -> None:
|
||||||
|
if self._simulate_latency:
|
||||||
|
low, high = self._latency_range_ms
|
||||||
|
delay = random.randint(low, high) / 1000.0
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
|
||||||
|
def _maybe_error(self) -> None:
|
||||||
|
if self._error_rate > 0 and random.random() < self._error_rate:
|
||||||
|
error_types = [
|
||||||
|
("TIMEOUT", "Connection timed out"),
|
||||||
|
("CONNECTION_REFUSED", "ERP server refused connection"),
|
||||||
|
("AUTH_FAILED", "Authentication failed"),
|
||||||
|
("RATE_LIMITED", "ERP rate limit exceeded"),
|
||||||
|
("INTERNAL_ERROR", "ERP internal server error"),
|
||||||
|
]
|
||||||
|
code, msg = random.choice(error_types)
|
||||||
|
from src.core.exceptions import ConnectorException
|
||||||
|
raise ConnectorException(self.connector_type, msg, details={"error_code": code})
|
||||||
|
|
||||||
|
def _metric(self, operation: str, status: str) -> None:
|
||||||
|
CONNECTOR_OPERATIONS.labels(connector_type=self.connector_type, operation=operation, status=status).inc()
|
||||||
|
|
||||||
|
async def get_stock(self, pharmacy_id: UUID, medication_id: UUID | None = None, nregistro: str | None = None) -> ConnectorResult:
|
||||||
|
await self._maybe_delay()
|
||||||
|
self._maybe_error()
|
||||||
|
logger.info("mock_get_stock", extra={"pharmacy_id": str(pharmacy_id), "nregistro": nregistro})
|
||||||
|
|
||||||
|
if nregistro and nregistro in self._stock_data:
|
||||||
|
data = dict(self._stock_data[nregistro])
|
||||||
|
data["pharmacy_id"] = str(pharmacy_id)
|
||||||
|
self._metric("get_stock", "success")
|
||||||
|
return ConnectorResult(success=True, data=data)
|
||||||
|
|
||||||
|
if nregistro is None and medication_id is None:
|
||||||
|
results = [dict(v) for v in self._stock_data.values()]
|
||||||
|
for r in results:
|
||||||
|
r["pharmacy_id"] = str(pharmacy_id)
|
||||||
|
self._metric("get_stock", "success")
|
||||||
|
return ConnectorResult(success=True, data={"items": results, "total": len(results)})
|
||||||
|
|
||||||
|
self._metric("get_stock", "not_found")
|
||||||
|
return ConnectorResult(success=False, error=f"Medication '{nregistro}' not found in mock data")
|
||||||
|
|
||||||
|
async def reserve(self, pharmacy_id: UUID, medication_id: UUID, quantity: int, idempotency_key: str | None = None) -> ConnectorResult:
|
||||||
|
await self._maybe_delay()
|
||||||
|
self._maybe_error()
|
||||||
|
logger.info("mock_reserve", extra={"pharmacy_id": str(pharmacy_id), "quantity": quantity})
|
||||||
|
|
||||||
|
if idempotency_key and idempotency_key in self._reservations:
|
||||||
|
self._metric("reserve", "idempotent")
|
||||||
|
return ConnectorResult(success=True, data=self._reservations[idempotency_key])
|
||||||
|
|
||||||
|
reservation_id = f"mock-res-{idempotency_key or random.randint(100000, 999999)}"
|
||||||
|
reservation_data = {
|
||||||
|
"reservation_id": reservation_id,
|
||||||
|
"status": "confirmed",
|
||||||
|
"quantity": quantity,
|
||||||
|
"pharmacy_id": str(pharmacy_id),
|
||||||
|
"medication_id": str(medication_id),
|
||||||
|
"confirmed_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"expires_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
self._reservations[reservation_id] = reservation_data
|
||||||
|
if idempotency_key:
|
||||||
|
self._reservations[idempotency_key] = reservation_data
|
||||||
|
|
||||||
|
self._metric("reserve", "success")
|
||||||
|
return ConnectorResult(success=True, data=reservation_data)
|
||||||
|
|
||||||
|
async def cancel_reservation(self, pharmacy_id: UUID, reservation_id: str) -> ConnectorResult:
|
||||||
|
await self._maybe_delay()
|
||||||
|
self._maybe_error()
|
||||||
|
logger.info("mock_cancel_reservation", extra={"reservation_id": reservation_id})
|
||||||
|
|
||||||
|
if reservation_id in self._reservations:
|
||||||
|
self._reservations[reservation_id]["status"] = "cancelled"
|
||||||
|
self._metric("cancel_reservation", "success")
|
||||||
|
return ConnectorResult(success=True, data={"reservation_id": reservation_id, "status": "cancelled"})
|
||||||
|
|
||||||
|
self._metric("cancel_reservation", "not_found")
|
||||||
|
return ConnectorResult(success=False, error=f"Reservation '{reservation_id}' not found")
|
||||||
|
|
||||||
|
async def pharmacy_information(self, pharmacy_id: UUID) -> ConnectorResult:
|
||||||
|
await self._maybe_delay()
|
||||||
|
self._maybe_error()
|
||||||
|
logger.info("mock_pharmacy_information", extra={"pharmacy_id": str(pharmacy_id)})
|
||||||
|
|
||||||
|
pharmacy = list(self._pharmacies.values())[0]
|
||||||
|
result = dict(pharmacy)
|
||||||
|
result["pharmacy_id"] = str(pharmacy_id)
|
||||||
|
self._metric("pharmacy_information", "success")
|
||||||
|
return ConnectorResult(success=True, data=result)
|
||||||
|
|
||||||
|
async def synchronize(self, pharmacy_id: UUID, strategy: str = "incremental", since: str | None = None) -> ConnectorResult:
|
||||||
|
await self._maybe_delay()
|
||||||
|
self._maybe_error()
|
||||||
|
logger.info("mock_synchronize", extra={"pharmacy_id": str(pharmacy_id), "strategy": strategy, "since": since})
|
||||||
|
|
||||||
|
items_total = len(self._stock_data)
|
||||||
|
items_processed = items_total
|
||||||
|
items_failed = random.randint(0, min(2, items_total)) if self._error_rate > 0 else 0
|
||||||
|
if items_failed > 0:
|
||||||
|
items_processed = items_total - items_failed
|
||||||
|
|
||||||
|
self._metric("synchronize", "success")
|
||||||
|
return ConnectorResult(
|
||||||
|
success=True,
|
||||||
|
data={
|
||||||
|
"items_total": items_total,
|
||||||
|
"items_processed": items_processed,
|
||||||
|
"items_failed": items_failed,
|
||||||
|
"strategy": strategy,
|
||||||
|
"synced_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"pharmacy_id": str(pharmacy_id),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def heartbeat(self, pharmacy_id: UUID) -> ConnectorResult:
|
||||||
|
self._metric("heartbeat", "success")
|
||||||
|
return ConnectorResult(
|
||||||
|
success=True,
|
||||||
|
data={
|
||||||
|
"status": "healthy",
|
||||||
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"connector_type": self.connector_type,
|
||||||
|
"pharmacy_id": str(pharmacy_id),
|
||||||
|
"circuit_breaker": "closed",
|
||||||
|
"stock_items": len(self._stock_data),
|
||||||
|
"active_reservations": len([r for r in self._reservations.values() if r.get("status") == "confirmed"]),
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from src.connectors.base import BaseInventoryConnector, ConnectorResult
|
||||||
|
from src.core.exceptions import ConnectorException
|
||||||
|
from src.core.logging import get_logger
|
||||||
|
from src.core.metrics import CONNECTOR_OPERATIONS
|
||||||
|
from src.domain.entities import ConnectorStatus, ERPType
|
||||||
|
from src.infrastructure.cache.redis_cache import redis_cache
|
||||||
|
from src.infrastructure.database.repositories import ConnectorConfigRepository, ConnectorRepository
|
||||||
|
from src.infrastructure.messaging.rabbitmq import rabbitmq_client
|
||||||
|
|
||||||
|
logger = get_logger("connector_registry")
|
||||||
|
|
||||||
|
_BUILTIN_CONNECTORS: dict[str, str] = {
|
||||||
|
ERPType.MOCK.value: "src.connectors.mock.mock_connector.MockConnector",
|
||||||
|
ERPType.FARMATIC.value: "src.connectors.farmatic.farmatic_connector.FarmaticConnector",
|
||||||
|
ERPType.CUSTOM_REST.value: "src.connectors.rest.rest_connector.RESTConnector",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectorRegistry:
|
||||||
|
_instance: ConnectorRegistry | None = None
|
||||||
|
_connectors: dict[str, BaseInventoryConnector]
|
||||||
|
_instances: dict[UUID, BaseInventoryConnector]
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._connectors = {}
|
||||||
|
self._instances = {}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_registry(cls) -> ConnectorRegistry:
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = cls()
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
def register(self, connector_type: str, connector: BaseInventoryConnector) -> None:
|
||||||
|
self._connectors[connector_type] = connector
|
||||||
|
logger.info("connector_registered", extra={"connector_type": connector_type})
|
||||||
|
|
||||||
|
def unregister(self, connector_type: str) -> None:
|
||||||
|
self._connectors.pop(connector_type, None)
|
||||||
|
logger.info("connector_unregistered", extra={"connector_type": connector_type})
|
||||||
|
|
||||||
|
def get_connector_class(self, connector_type: str) -> type[BaseInventoryConnector]:
|
||||||
|
if connector_type in self._connectors:
|
||||||
|
return type(self._connectors[connector_type])
|
||||||
|
|
||||||
|
class_path = _BUILTIN_CONNECTORS.get(connector_type)
|
||||||
|
if not class_path:
|
||||||
|
raise ConnectorException(connector_type, f"No connector registered for type '{connector_type}'")
|
||||||
|
|
||||||
|
try:
|
||||||
|
module_path, class_name = class_path.rsplit(".", 1)
|
||||||
|
module = importlib.import_module(module_path)
|
||||||
|
cls = getattr(module, class_name)
|
||||||
|
self._connectors[connector_type] = cls()
|
||||||
|
return cls
|
||||||
|
except (ImportError, AttributeError) as e:
|
||||||
|
raise ConnectorException(connector_type, f"Failed to load connector '{connector_type}': {e}") from e
|
||||||
|
|
||||||
|
def create_instance(self, connector_type: str, config: dict[str, Any] | None = None) -> BaseInventoryConnector:
|
||||||
|
cls = self.get_connector_class(connector_type)
|
||||||
|
instance = cls()
|
||||||
|
if config and hasattr(instance, "configure"):
|
||||||
|
instance.configure(config)
|
||||||
|
return instance
|
||||||
|
|
||||||
|
def get_or_create_instance(self, connector_id: UUID, connector_type: str, config: dict[str, Any] | None = None) -> BaseInventoryConnector:
|
||||||
|
if connector_id in self._instances:
|
||||||
|
return self._instances[connector_id]
|
||||||
|
instance = self.create_instance(connector_type, config)
|
||||||
|
self._instances[connector_id] = instance
|
||||||
|
return instance
|
||||||
|
|
||||||
|
def remove_instance(self, connector_id: UUID) -> None:
|
||||||
|
self._instances.pop(connector_id, None)
|
||||||
|
|
||||||
|
def list_registered_types(self) -> list[str]:
|
||||||
|
all_types = set(self._connectors.keys()) | set(_BUILTIN_CONNECTORS.keys())
|
||||||
|
return sorted(all_types)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def record_heartbeat(repo: ConnectorRepository, connector_id: UUID) -> None:
|
||||||
|
entity = await repo.get_by_id(connector_id)
|
||||||
|
if entity:
|
||||||
|
entity.last_heartbeat_at = datetime.now(timezone.utc)
|
||||||
|
entity.status = ConnectorStatus.ACTIVE
|
||||||
|
entity.last_error = None
|
||||||
|
await repo.update(entity)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def record_error(repo: ConnectorRepository, connector_id: UUID, error: str) -> None:
|
||||||
|
entity = await repo.get_by_id(connector_id)
|
||||||
|
if entity:
|
||||||
|
entity.last_error = error
|
||||||
|
entity.status = ConnectorStatus.ERROR
|
||||||
|
await repo.update(entity)
|
||||||
|
|
||||||
|
|
||||||
|
connector_registry = ConnectorRegistry.get_registry()
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from src.connectors.rest.rest_connector import RESTConnector
|
||||||
|
|
||||||
|
__all__ = ["RESTConnector"]
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from src.connectors.sdk.base_sdk import SDKConnector
|
||||||
|
from src.connectors.base import ConnectorResult
|
||||||
|
from src.core.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger("rest_connector")
|
||||||
|
|
||||||
|
|
||||||
|
class RESTConnector(SDKConnector):
|
||||||
|
connector_type: str = "custom_rest"
|
||||||
|
|
||||||
|
_STOCK_PATH: str = "/stock"
|
||||||
|
_RESERVE_PATH: str = "/reservations"
|
||||||
|
_PHARMACY_PATH: str = "/pharmacy"
|
||||||
|
_SYNC_PATH: str = "/sync"
|
||||||
|
_HEALTH_PATH: str = "/health"
|
||||||
|
|
||||||
|
def configure(self, config: dict[str, Any]) -> None:
|
||||||
|
self._base_url = config.get("base_url", "")
|
||||||
|
self._timeout = config.get("timeout", 30.0)
|
||||||
|
self._STOCK_PATH = config.get("stock_path", self._STOCK_PATH)
|
||||||
|
self._RESERVE_PATH = config.get("reserve_path", self._RESERVE_PATH)
|
||||||
|
self._PHARMACY_PATH = config.get("pharmacy_path", self._PHARMACY_PATH)
|
||||||
|
self._SYNC_PATH = config.get("sync_path", self._SYNC_PATH)
|
||||||
|
self._HEALTH_PATH = config.get("health_path", self._HEALTH_PATH)
|
||||||
|
super().configure(config)
|
||||||
|
|
||||||
|
async def get_stock(self, pharmacy_id: UUID, medication_id: UUID | None = None, nregistro: str | None = None) -> ConnectorResult:
|
||||||
|
try:
|
||||||
|
params: dict[str, Any] = {"pharmacy_id": str(pharmacy_id)}
|
||||||
|
if nregistro:
|
||||||
|
params["nregistro"] = nregistro
|
||||||
|
if medication_id:
|
||||||
|
params["medication_id"] = str(medication_id)
|
||||||
|
response = await self._request("GET", self._STOCK_PATH, params=params)
|
||||||
|
self._record_metric("get_stock", "success")
|
||||||
|
return ConnectorResult(success=True, data=response.json())
|
||||||
|
except Exception as exc:
|
||||||
|
self._record_metric("get_stock", "error")
|
||||||
|
return ConnectorResult(success=False, error=str(exc))
|
||||||
|
|
||||||
|
async def reserve(self, pharmacy_id: UUID, medication_id: UUID, quantity: int, idempotency_key: str | None = None) -> ConnectorResult:
|
||||||
|
try:
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"pharmacy_id": str(pharmacy_id),
|
||||||
|
"medication_id": str(medication_id),
|
||||||
|
"quantity": quantity,
|
||||||
|
}
|
||||||
|
if idempotency_key:
|
||||||
|
payload["idempotency_key"] = idempotency_key
|
||||||
|
response = await self._request("POST", self._RESERVE_PATH, json=payload)
|
||||||
|
self._record_metric("reserve", "success")
|
||||||
|
return ConnectorResult(success=True, data=response.json())
|
||||||
|
except Exception as exc:
|
||||||
|
self._record_metric("reserve", "error")
|
||||||
|
return ConnectorResult(success=False, error=str(exc))
|
||||||
|
|
||||||
|
async def cancel_reservation(self, pharmacy_id: UUID, reservation_id: str) -> ConnectorResult:
|
||||||
|
try:
|
||||||
|
response = await self._request("DELETE", f"{self._RESERVE_PATH}/{reservation_id}", json={"pharmacy_id": str(pharmacy_id)})
|
||||||
|
self._record_metric("cancel_reservation", "success")
|
||||||
|
return ConnectorResult(success=True, data=response.json())
|
||||||
|
except Exception as exc:
|
||||||
|
self._record_metric("cancel_reservation", "error")
|
||||||
|
return ConnectorResult(success=False, error=str(exc))
|
||||||
|
|
||||||
|
async def pharmacy_information(self, pharmacy_id: UUID) -> ConnectorResult:
|
||||||
|
try:
|
||||||
|
response = await self._request("GET", f"{self._PHARMACY_PATH}/{pharmacy_id}")
|
||||||
|
self._record_metric("pharmacy_information", "success")
|
||||||
|
return ConnectorResult(success=True, data=response.json())
|
||||||
|
except Exception as exc:
|
||||||
|
self._record_metric("pharmacy_information", "error")
|
||||||
|
return ConnectorResult(success=False, error=str(exc))
|
||||||
|
|
||||||
|
async def synchronize(self, pharmacy_id: UUID, strategy: str = "incremental", since: str | None = None) -> ConnectorResult:
|
||||||
|
try:
|
||||||
|
payload: dict[str, Any] = {"pharmacy_id": str(pharmacy_id), "strategy": strategy}
|
||||||
|
if since:
|
||||||
|
payload["since"] = since
|
||||||
|
response = await self._request("POST", self._SYNC_PATH, json=payload)
|
||||||
|
self._record_metric("synchronize", "success")
|
||||||
|
return ConnectorResult(success=True, data=response.json())
|
||||||
|
except Exception as exc:
|
||||||
|
self._record_metric("synchronize", "error")
|
||||||
|
return ConnectorResult(success=False, error=str(exc))
|
||||||
|
|
||||||
|
async def heartbeat(self, pharmacy_id: UUID) -> ConnectorResult:
|
||||||
|
try:
|
||||||
|
response = await self._request("GET", self._HEALTH_PATH)
|
||||||
|
data = response.json()
|
||||||
|
data["connector_type"] = self.connector_type
|
||||||
|
data["pharmacy_id"] = str(pharmacy_id)
|
||||||
|
data["circuit_breaker"] = self._circuit_breaker.state.value
|
||||||
|
self._record_metric("heartbeat", "success")
|
||||||
|
return ConnectorResult(success=True, data=data)
|
||||||
|
except Exception as exc:
|
||||||
|
self._record_metric("heartbeat", "error")
|
||||||
|
return ConnectorResult(success=False, error=str(exc))
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
from .context import ConnectorContext
|
||||||
|
from .auth import AuthHandler, BasicAuthHandler, BearerAuthHandler, APIKeyAuthHandler, OAuth2Handler
|
||||||
|
from .retry import RetryPolicy, with_retry
|
||||||
|
from .circuit_breaker import CircuitBreaker, CircuitState
|
||||||
|
from .base_sdk import SDKConnector
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"APIKeyAuthHandler",
|
||||||
|
"AuthHandler",
|
||||||
|
"BasicAuthHandler",
|
||||||
|
"BearerAuthHandler",
|
||||||
|
"CircuitBreaker",
|
||||||
|
"CircuitState",
|
||||||
|
"ConnectorContext",
|
||||||
|
"OAuth2Handler",
|
||||||
|
"RetryPolicy",
|
||||||
|
"SDKConnector",
|
||||||
|
"with_retry",
|
||||||
|
]
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from src.connectors.sdk.context import ConnectorContext
|
||||||
|
from src.core.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger("connector_auth")
|
||||||
|
|
||||||
|
|
||||||
|
class AuthHandler(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
async def apply(self, request: httpx.Request, context: ConnectorContext) -> httpx.Request:
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def refresh(self, context: ConnectorContext) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class BasicAuthHandler(AuthHandler):
|
||||||
|
def __init__(self, username_key: str = "username", password_key: str = "password") -> None:
|
||||||
|
self._username_key = username_key
|
||||||
|
self._password_key = password_key
|
||||||
|
|
||||||
|
async def apply(self, request: httpx.Request, context: ConnectorContext) -> httpx.Request:
|
||||||
|
import base64
|
||||||
|
|
||||||
|
username = context.get_secret(self._username_key) or context.get_config(self._username_key, "")
|
||||||
|
password = context.get_secret(self._password_key) or context.get_config(self._password_key, "")
|
||||||
|
credentials = base64.b64encode(f"{username}:{password}".encode()).decode()
|
||||||
|
request.headers["Authorization"] = f"Basic {credentials}"
|
||||||
|
return request
|
||||||
|
|
||||||
|
async def refresh(self, context: ConnectorContext) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class BearerAuthHandler(AuthHandler):
|
||||||
|
def __init__(self, token_key: str = "access_token") -> None:
|
||||||
|
self._token_key = token_key
|
||||||
|
self._token: str | None = None
|
||||||
|
|
||||||
|
async def apply(self, request: httpx.Request, context: ConnectorContext) -> httpx.Request:
|
||||||
|
token = self._token or context.get_secret(self._token_key) or context.get_config(self._token_key, "")
|
||||||
|
if token:
|
||||||
|
request.headers["Authorization"] = f"Bearer {token}"
|
||||||
|
return request
|
||||||
|
|
||||||
|
async def refresh(self, context: ConnectorContext) -> bool:
|
||||||
|
self._token = None
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class APIKeyAuthHandler(AuthHandler):
|
||||||
|
def __init__(self, header_name: str = "X-API-Key", key_secret: str = "api_key") -> None:
|
||||||
|
self._header_name = header_name
|
||||||
|
self._key_secret = key_secret
|
||||||
|
|
||||||
|
async def apply(self, request: httpx.Request, context: ConnectorContext) -> httpx.Request:
|
||||||
|
api_key = context.get_secret(self._key_secret) or context.get_config(self._key_secret, "")
|
||||||
|
if api_key:
|
||||||
|
request.headers[self._header_name] = api_key
|
||||||
|
return request
|
||||||
|
|
||||||
|
async def refresh(self, context: ConnectorContext) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class OAuth2Handler(AuthHandler):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
token_url_key: str = "token_url",
|
||||||
|
client_id_key: str = "client_id",
|
||||||
|
client_secret_key: str = "client_secret",
|
||||||
|
) -> None:
|
||||||
|
self._token_url_key = token_url_key
|
||||||
|
self._client_id_key = client_id_key
|
||||||
|
self._client_secret_key = client_secret_key
|
||||||
|
self._access_token: str | None = None
|
||||||
|
self._token_expires: float = 0
|
||||||
|
|
||||||
|
async def apply(self, request: httpx.Request, context: ConnectorContext) -> httpx.Request:
|
||||||
|
import time
|
||||||
|
|
||||||
|
if not self._access_token or time.time() >= self._token_expires:
|
||||||
|
await self._fetch_token(context)
|
||||||
|
|
||||||
|
if self._access_token:
|
||||||
|
request.headers["Authorization"] = f"Bearer {self._access_token}"
|
||||||
|
return request
|
||||||
|
|
||||||
|
async def _fetch_token(self, context: ConnectorContext) -> None:
|
||||||
|
import time
|
||||||
|
|
||||||
|
token_url = context.require_config(self._token_url_key)
|
||||||
|
client_id = context.require_secret(self._client_id_key)
|
||||||
|
client_secret = context.require_secret(self._client_secret_key)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.post(
|
||||||
|
token_url,
|
||||||
|
data={
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"client_id": client_id,
|
||||||
|
"client_secret": client_secret,
|
||||||
|
},
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
self._access_token = data["access_token"]
|
||||||
|
self._token_expires = time.time() + data.get("expires_in", 3600) - 30
|
||||||
|
|
||||||
|
logger.info("oauth2_token_acquired", extra={"token_url": token_url})
|
||||||
|
|
||||||
|
async def refresh(self, context: ConnectorContext) -> bool:
|
||||||
|
self._access_token = None
|
||||||
|
self._token_expires = 0
|
||||||
|
return True
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import abstractmethod
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from src.connectors.base import BaseInventoryConnector, ConnectorResult
|
||||||
|
from src.connectors.sdk.auth import AuthHandler, BearerAuthHandler
|
||||||
|
from src.connectors.sdk.circuit_breaker import CircuitBreaker
|
||||||
|
from src.connectors.sdk.context import ConnectorContext
|
||||||
|
from src.connectors.sdk.retry import RetryPolicy, with_retry
|
||||||
|
from src.core.exceptions import ConnectorException
|
||||||
|
from src.core.logging import get_logger
|
||||||
|
from src.core.metrics import CONNECTOR_OPERATIONS
|
||||||
|
|
||||||
|
logger = get_logger("sdk_connector")
|
||||||
|
|
||||||
|
|
||||||
|
class SDKConnector(BaseInventoryConnector):
|
||||||
|
connector_type: str = "sdk"
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._context: ConnectorContext | None = None
|
||||||
|
self._auth_handler: AuthHandler = BearerAuthHandler()
|
||||||
|
self._retry_policy: RetryPolicy = RetryPolicy()
|
||||||
|
self._circuit_breaker: CircuitBreaker = CircuitBreaker(name=self.connector_type)
|
||||||
|
self._http_client: httpx.AsyncClient | None = None
|
||||||
|
self._base_url: str = ""
|
||||||
|
self._timeout: float = 30.0
|
||||||
|
|
||||||
|
def configure(self, config: dict[str, Any]) -> None:
|
||||||
|
self._base_url = config.get("base_url", "")
|
||||||
|
self._timeout = config.get("timeout", 30.0)
|
||||||
|
auth_type = config.get("auth_type", "bearer")
|
||||||
|
if auth_type == "basic":
|
||||||
|
from src.connectors.sdk.auth import BasicAuthHandler
|
||||||
|
self._auth_handler = BasicAuthHandler()
|
||||||
|
elif auth_type == "api_key":
|
||||||
|
from src.connectors.sdk.auth import APIKeyAuthHandler
|
||||||
|
self._auth_handler = APIKeyAuthHandler(
|
||||||
|
header_name=config.get("api_key_header", "X-API-Key"),
|
||||||
|
key_secret=config.get("api_key_secret_key", "api_key"),
|
||||||
|
)
|
||||||
|
elif auth_type == "oauth2":
|
||||||
|
from src.connectors.sdk.auth import OAuth2Handler
|
||||||
|
self._auth_handler = OAuth2Handler()
|
||||||
|
|
||||||
|
cb_config = config.get("circuit_breaker", {})
|
||||||
|
if cb_config:
|
||||||
|
self._circuit_breaker = CircuitBreaker(
|
||||||
|
name=self.connector_type,
|
||||||
|
failure_threshold=cb_config.get("failure_threshold", 5),
|
||||||
|
recovery_timeout=cb_config.get("recovery_timeout", 30.0),
|
||||||
|
)
|
||||||
|
|
||||||
|
retry_config = config.get("retry", {})
|
||||||
|
if retry_config:
|
||||||
|
self._retry_policy = RetryPolicy(
|
||||||
|
max_attempts=retry_config.get("max_attempts", 3),
|
||||||
|
base_delay=retry_config.get("base_delay", 1.0),
|
||||||
|
max_delay=retry_config.get("max_delay", 30.0),
|
||||||
|
)
|
||||||
|
|
||||||
|
def set_context(self, context: ConnectorContext) -> None:
|
||||||
|
self._context = context
|
||||||
|
if context.config:
|
||||||
|
self.configure(context.config)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def http_client(self) -> httpx.AsyncClient:
|
||||||
|
if self._http_client is None or self._http_client.is_closed:
|
||||||
|
self._http_client = httpx.AsyncClient(
|
||||||
|
base_url=self._base_url,
|
||||||
|
timeout=self._timeout,
|
||||||
|
)
|
||||||
|
return self._http_client
|
||||||
|
|
||||||
|
async def _request(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
json: dict[str, Any] | None = None,
|
||||||
|
params: dict[str, Any] | None = None,
|
||||||
|
headers: dict[str, str] | None = None,
|
||||||
|
) -> httpx.Response:
|
||||||
|
if not self._context:
|
||||||
|
raise ConnectorException(self.connector_type, "Connector context not set. Call set_context() first.")
|
||||||
|
|
||||||
|
async def _do_request() -> httpx.Response:
|
||||||
|
request = self.http_client.build_request(method, path, json=json, params=params, headers=headers)
|
||||||
|
request = await self._auth_handler.apply(request, self._context)
|
||||||
|
response = await self.http_client.send(request)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response
|
||||||
|
|
||||||
|
return await self._circuit_breaker.call(
|
||||||
|
with_retry,
|
||||||
|
_do_request,
|
||||||
|
policy=self._retry_policy,
|
||||||
|
connector_type=self.connector_type,
|
||||||
|
operation=f"{method}:{path}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _record_metric(self, operation: str, status: str) -> None:
|
||||||
|
CONNECTOR_OPERATIONS.labels(connector_type=self.connector_type, operation=operation, status=status).inc()
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
if self._http_client and not self._http_client.is_closed:
|
||||||
|
await self._http_client.aclose()
|
||||||
|
self._http_client = None
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_stock(self, pharmacy_id: UUID, medication_id: UUID | None = None, nregistro: str | None = None) -> ConnectorResult:
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def reserve(self, pharmacy_id: UUID, medication_id: UUID, quantity: int, idempotency_key: str | None = None) -> ConnectorResult:
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def cancel_reservation(self, pharmacy_id: UUID, reservation_id: str) -> ConnectorResult:
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def pharmacy_information(self, pharmacy_id: UUID) -> ConnectorResult:
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def synchronize(self, pharmacy_id: UUID, strategy: str = "incremental", since: str | None = None) -> ConnectorResult:
|
||||||
|
...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def heartbeat(self, pharmacy_id: UUID) -> ConnectorResult:
|
||||||
|
...
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from src.core.exceptions import ConnectorException, ServiceUnavailableException
|
||||||
|
from src.core.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger("circuit_breaker")
|
||||||
|
|
||||||
|
|
||||||
|
class CircuitState(str, Enum):
|
||||||
|
CLOSED = "closed"
|
||||||
|
OPEN = "open"
|
||||||
|
HALF_OPEN = "half_open"
|
||||||
|
|
||||||
|
|
||||||
|
class CircuitBreaker:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
failure_threshold: int = 5,
|
||||||
|
recovery_timeout: float = 30.0,
|
||||||
|
half_open_max_calls: int = 1,
|
||||||
|
) -> None:
|
||||||
|
self._name = name
|
||||||
|
self._failure_threshold = failure_threshold
|
||||||
|
self._recovery_timeout = recovery_timeout
|
||||||
|
self._half_open_max_calls = half_open_max_calls
|
||||||
|
|
||||||
|
self._state = CircuitState.CLOSED
|
||||||
|
self._failure_count = 0
|
||||||
|
self._success_count = 0
|
||||||
|
self._last_failure_time: float = 0
|
||||||
|
self._half_open_calls: int = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state(self) -> CircuitState:
|
||||||
|
if self._state == CircuitState.OPEN:
|
||||||
|
if time.monotonic() - self._last_failure_time >= self._recovery_timeout:
|
||||||
|
self._state = CircuitState.HALF_OPEN
|
||||||
|
self._half_open_calls = 0
|
||||||
|
logger.info("circuit_half_open", extra={"name": self._name})
|
||||||
|
return self._state
|
||||||
|
|
||||||
|
async def call(self, func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
|
||||||
|
current_state = self.state
|
||||||
|
|
||||||
|
if current_state == CircuitState.OPEN:
|
||||||
|
raise ServiceUnavailableException(
|
||||||
|
f"circuit_breaker:{self._name}",
|
||||||
|
f"Circuit breaker '{self._name}' is open. Retry after {self._recovery_timeout}s.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if current_state == CircuitState.HALF_OPEN:
|
||||||
|
if self._half_open_calls >= self._half_open_max_calls:
|
||||||
|
raise ServiceUnavailableException(
|
||||||
|
f"circuit_breaker:{self._name}",
|
||||||
|
f"Circuit breaker '{self._name}' is half-open with max concurrent calls reached.",
|
||||||
|
)
|
||||||
|
self._half_open_calls += 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await func(*args, **kwargs)
|
||||||
|
self._on_success()
|
||||||
|
return result
|
||||||
|
except Exception as exc:
|
||||||
|
self._on_failure(exc)
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _on_success(self) -> None:
|
||||||
|
if self._state == CircuitState.HALF_OPEN:
|
||||||
|
self._state = CircuitState.CLOSED
|
||||||
|
logger.info("circuit_closed", extra={"name": self._name})
|
||||||
|
self._failure_count = 0
|
||||||
|
self._success_count += 1
|
||||||
|
|
||||||
|
def _on_failure(self, exc: Exception) -> None:
|
||||||
|
self._failure_count += 1
|
||||||
|
self._last_failure_time = time.monotonic()
|
||||||
|
self._success_count = 0
|
||||||
|
|
||||||
|
if self._state == CircuitState.HALF_OPEN:
|
||||||
|
self._state = CircuitState.OPEN
|
||||||
|
logger.warning("circuit_reopened", extra={"name": self._name, "error": str(exc)})
|
||||||
|
elif self._failure_count >= self._failure_threshold:
|
||||||
|
self._state = CircuitState.OPEN
|
||||||
|
logger.warning("circuit_opened", extra={"name": self._name, "failure_count": self._failure_count})
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
self._state = CircuitState.CLOSED
|
||||||
|
self._failure_count = 0
|
||||||
|
self._success_count = 0
|
||||||
|
self._last_failure_time = 0
|
||||||
|
|
||||||
|
def get_status(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"name": self._name,
|
||||||
|
"state": self.state.value,
|
||||||
|
"failure_count": self._failure_count,
|
||||||
|
"success_count": self._success_count,
|
||||||
|
"failure_threshold": self._failure_threshold,
|
||||||
|
"recovery_timeout": self._recovery_timeout,
|
||||||
|
"last_failure_time": self._last_failure_time,
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ConnectorContext:
|
||||||
|
connector_id: UUID
|
||||||
|
pharmacy_id: UUID
|
||||||
|
erp_system_id: UUID
|
||||||
|
connector_type: str
|
||||||
|
config: dict[str, Any] = field(default_factory=dict)
|
||||||
|
secrets: dict[str, str] = field(default_factory=dict)
|
||||||
|
|
||||||
|
_cache_prefix: str = field(default="connector", init=False, repr=False)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cache_key_prefix(self) -> str:
|
||||||
|
return f"{self._cache_prefix}:{self.connector_type}:{self.pharmacy_id}"
|
||||||
|
|
||||||
|
def cache_key(self, suffix: str) -> str:
|
||||||
|
return f"{self.cache_key_prefix}:{suffix}"
|
||||||
|
|
||||||
|
def get_config(self, key: str, default: Any = None) -> Any:
|
||||||
|
return self.config.get(key, default)
|
||||||
|
|
||||||
|
def get_secret(self, key: str) -> str | None:
|
||||||
|
return self.secrets.get(key)
|
||||||
|
|
||||||
|
def require_secret(self, key: str) -> str:
|
||||||
|
value = self.secrets.get(key)
|
||||||
|
if value is None:
|
||||||
|
raise ValueError(f"Required secret '{key}' not found in connector context")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def require_config(self, key: str) -> Any:
|
||||||
|
value = self.config.get(key)
|
||||||
|
if value is None:
|
||||||
|
raise ValueError(f"Required config '{key}' not found in connector context")
|
||||||
|
return value
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from functools import wraps
|
||||||
|
from typing import Any, Callable, TypeVar
|
||||||
|
|
||||||
|
from src.core.exceptions import ConnectorException
|
||||||
|
from src.core.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger("connector_retry")
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
DEFAULT_RETRYABLE_EXCEPTIONS = (ConnectionError, TimeoutError, OSError)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RetryPolicy:
|
||||||
|
max_attempts: int = 3
|
||||||
|
base_delay: float = 1.0
|
||||||
|
max_delay: float = 30.0
|
||||||
|
exponential_base: float = 2.0
|
||||||
|
jitter: bool = True
|
||||||
|
retryable_exceptions: tuple[type[Exception], ...] = field(default_factory=lambda: DEFAULT_RETRYABLE_EXCEPTIONS)
|
||||||
|
|
||||||
|
def delay_for_attempt(self, attempt: int) -> float:
|
||||||
|
import random
|
||||||
|
|
||||||
|
delay = min(self.base_delay * (self.exponential_base ** (attempt - 1)), self.max_delay)
|
||||||
|
if self.jitter:
|
||||||
|
delay = delay * (0.5 + random.random() * 0.5)
|
||||||
|
return delay
|
||||||
|
|
||||||
|
def should_retry(self, exception: Exception) -> bool:
|
||||||
|
if isinstance(exception, ConnectorException):
|
||||||
|
return False
|
||||||
|
return isinstance(exception, self.retryable_exceptions)
|
||||||
|
|
||||||
|
|
||||||
|
async def with_retry(
|
||||||
|
func: Callable[..., Any],
|
||||||
|
*args: Any,
|
||||||
|
policy: RetryPolicy | None = None,
|
||||||
|
connector_type: str = "unknown",
|
||||||
|
operation: str = "unknown",
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> Any:
|
||||||
|
p = policy or RetryPolicy()
|
||||||
|
last_exception: Exception | None = None
|
||||||
|
|
||||||
|
for attempt in range(1, p.max_attempts + 1):
|
||||||
|
try:
|
||||||
|
result = await func(*args, **kwargs)
|
||||||
|
if attempt > 1:
|
||||||
|
logger.info("retry_succeeded", extra={"connector_type": connector_type, "operation": operation, "attempt": attempt})
|
||||||
|
return result
|
||||||
|
except Exception as exc:
|
||||||
|
last_exception = exc
|
||||||
|
if not p.should_retry(exc) or attempt >= p.max_attempts:
|
||||||
|
break
|
||||||
|
delay = p.delay_for_attempt(attempt)
|
||||||
|
logger.warning(
|
||||||
|
"retry_attempt",
|
||||||
|
extra={
|
||||||
|
"connector_type": connector_type,
|
||||||
|
"operation": operation,
|
||||||
|
"attempt": attempt,
|
||||||
|
"delay_s": round(delay, 2),
|
||||||
|
"error": str(exc),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
|
||||||
|
raise last_exception # type: ignore[misc]
|
||||||
|
|
||||||
|
|
||||||
|
def retry(policy: RetryPolicy | None = None):
|
||||||
|
p = policy or RetryPolicy()
|
||||||
|
|
||||||
|
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
||||||
|
@wraps(func)
|
||||||
|
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||||
|
connector_type = getattr(args[0], "connector_type", "unknown") if args else "unknown"
|
||||||
|
return await with_retry(func, *args, policy=p, connector_type=connector_type, operation=func.__name__, **kwargs)
|
||||||
|
return wrapper
|
||||||
|
return decorator
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
|
||||||
|
class PIPException(Exception):
|
||||||
|
def __init__(self, message: str, code: str = "PIP_ERROR", status_code: int = 500, details: dict | None = None):
|
||||||
|
super().__init__(message)
|
||||||
|
self.message = message
|
||||||
|
self.code = code
|
||||||
|
self.status_code = status_code
|
||||||
|
self.details = details or {}
|
||||||
|
self.timestamp = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"error": {
|
||||||
|
"code": self.code,
|
||||||
|
"message": self.message,
|
||||||
|
"details": self.details,
|
||||||
|
"timestamp": self.timestamp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class NotFoundException(PIPException):
|
||||||
|
def __init__(self, resource: str, resource_id: str | None = None):
|
||||||
|
msg = f"{resource} not found"
|
||||||
|
if resource_id:
|
||||||
|
msg = f"{resource} '{resource_id}' not found"
|
||||||
|
super().__init__(message=msg, code="NOT_FOUND", status_code=404, details={"resource": resource, "resource_id": resource_id})
|
||||||
|
|
||||||
|
|
||||||
|
class ConflictException(PIPException):
|
||||||
|
def __init__(self, message: str, details: dict | None = None):
|
||||||
|
super().__init__(message=message, code="CONFLICT", status_code=409, details=details)
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationException(PIPException):
|
||||||
|
def __init__(self, message: str, details: dict | None = None):
|
||||||
|
super().__init__(message=message, code="VALIDATION_ERROR", status_code=422, details=details)
|
||||||
|
|
||||||
|
|
||||||
|
class UnauthorizedException(PIPException):
|
||||||
|
def __init__(self, message: str = "Authentication required"):
|
||||||
|
super().__init__(message=message, code="UNAUTHORIZED", status_code=401)
|
||||||
|
|
||||||
|
|
||||||
|
class ForbiddenException(PIPException):
|
||||||
|
def __init__(self, message: str = "Insufficient permissions", required_role: str | None = None):
|
||||||
|
details = {}
|
||||||
|
if required_role:
|
||||||
|
details["required_role"] = required_role
|
||||||
|
super().__init__(message=message, code="FORBIDDEN", status_code=403, details=details)
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimitException(PIPException):
|
||||||
|
def __init__(self, message: str = "Rate limit exceeded"):
|
||||||
|
super().__init__(message=message, code="RATE_LIMIT_EXCEEDED", status_code=429)
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceUnavailableException(PIPException):
|
||||||
|
def __init__(self, service: str, message: str | None = None):
|
||||||
|
msg = message or f"Service '{service}' is unavailable"
|
||||||
|
super().__init__(message=msg, code="SERVICE_UNAVAILABLE", status_code=503, details={"service": service})
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectorException(PIPException):
|
||||||
|
def __init__(self, connector_type: str, message: str, details: dict | None = None):
|
||||||
|
d = details or {}
|
||||||
|
d["connector_type"] = connector_type
|
||||||
|
super().__init__(message=message, code="CONNECTOR_ERROR", status_code=502, details=d)
|
||||||
|
|
||||||
|
|
||||||
|
class SyncException(PIPException):
|
||||||
|
def __init__(self, pharmacy_id: str, message: str, details: dict | None = None):
|
||||||
|
d = details or {}
|
||||||
|
d["pharmacy_id"] = pharmacy_id
|
||||||
|
super().__init__(message=message, code="SYNC_ERROR", status_code=502, details=d)
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from src.infrastructure.config.settings import settings
|
||||||
|
|
||||||
|
|
||||||
|
def configure_logging() -> None:
|
||||||
|
shared_processors: list[Any] = [
|
||||||
|
structlog.contextvars.merge_contextvars,
|
||||||
|
structlog.stdlib.add_log_level,
|
||||||
|
structlog.stdlib.add_logger_name,
|
||||||
|
structlog.processors.TimeStamper(fmt="iso"),
|
||||||
|
structlog.processors.StackInfoRenderer(),
|
||||||
|
structlog.processors.UnicodeDecoder(),
|
||||||
|
]
|
||||||
|
|
||||||
|
if settings.LOG_JSON_FORMAT:
|
||||||
|
renderer = structlog.processors.JSONRenderer()
|
||||||
|
else:
|
||||||
|
renderer = structlog.dev.ConsoleRenderer()
|
||||||
|
|
||||||
|
structlog.configure(
|
||||||
|
processors=[
|
||||||
|
*shared_processors,
|
||||||
|
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
|
||||||
|
],
|
||||||
|
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||||
|
wrapper_class=structlog.stdlib.BoundLogger,
|
||||||
|
cache_logger_on_first_use=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
formatter = structlog.stdlib.ProcessorFormatter(
|
||||||
|
processors=[
|
||||||
|
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
|
||||||
|
renderer,
|
||||||
|
],
|
||||||
|
foreign_pre_chain=shared_processors,
|
||||||
|
)
|
||||||
|
|
||||||
|
handler = logging.StreamHandler(sys.stdout)
|
||||||
|
handler.setFormatter(formatter)
|
||||||
|
|
||||||
|
root_logger = logging.getLogger()
|
||||||
|
root_logger.handlers.clear()
|
||||||
|
root_logger.addHandler(handler)
|
||||||
|
root_logger.setLevel(settings.LOG_LEVEL.upper())
|
||||||
|
|
||||||
|
for noisy in ("uvicorn.access", "sqlalchemy.engine", "httpx", "aioredis"):
|
||||||
|
logging.getLogger(noisy).setLevel(logging.WARNING)
|
||||||
|
|
||||||
|
|
||||||
|
def get_logger(name: str | None = None) -> Any:
|
||||||
|
return structlog.get_logger(name)
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import time
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import Request, Response
|
||||||
|
from prometheus_client import Counter, Gauge, Histogram
|
||||||
|
|
||||||
|
REQUEST_COUNT = Counter(
|
||||||
|
"pip_http_requests_total",
|
||||||
|
"Total HTTP requests",
|
||||||
|
["method", "endpoint", "status_code"],
|
||||||
|
)
|
||||||
|
|
||||||
|
REQUEST_DURATION = Histogram(
|
||||||
|
"pip_http_request_duration_seconds",
|
||||||
|
"HTTP request duration in seconds",
|
||||||
|
["method", "endpoint"],
|
||||||
|
buckets=[0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0],
|
||||||
|
)
|
||||||
|
|
||||||
|
REQUESTS_IN_PROGRESS = Gauge(
|
||||||
|
"pip_http_requests_in_progress",
|
||||||
|
"HTTP requests currently in progress",
|
||||||
|
["method", "endpoint"],
|
||||||
|
)
|
||||||
|
|
||||||
|
DB_QUERY_DURATION = Histogram(
|
||||||
|
"pip_db_query_duration_seconds",
|
||||||
|
"Database query duration in seconds",
|
||||||
|
["operation"],
|
||||||
|
buckets=[0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0],
|
||||||
|
)
|
||||||
|
|
||||||
|
CONNECTOR_OPERATIONS = Counter(
|
||||||
|
"pip_connector_operations_total",
|
||||||
|
"Total connector operations",
|
||||||
|
["connector_type", "operation", "status"],
|
||||||
|
)
|
||||||
|
|
||||||
|
SYNC_JOBS = Counter(
|
||||||
|
"pip_sync_jobs_total",
|
||||||
|
"Total sync jobs",
|
||||||
|
["strategy", "status"],
|
||||||
|
)
|
||||||
|
|
||||||
|
SYNC_DURATION = Histogram(
|
||||||
|
"pip_sync_duration_seconds",
|
||||||
|
"Sync job duration in seconds",
|
||||||
|
["strategy"],
|
||||||
|
buckets=[1.0, 5.0, 10.0, 30.0, 60.0, 300.0, 600.0, 1800.0],
|
||||||
|
)
|
||||||
|
|
||||||
|
ACTIVE_CONNECTIONS = Gauge(
|
||||||
|
"pip_active_connections",
|
||||||
|
"Active infrastructure connections",
|
||||||
|
["type"],
|
||||||
|
)
|
||||||
|
|
||||||
|
CACHE_OPERATIONS = Counter(
|
||||||
|
"pip_cache_operations_total",
|
||||||
|
"Cache operations",
|
||||||
|
["operation", "result"],
|
||||||
|
)
|
||||||
|
|
||||||
|
RESERVATION_OPERATIONS = Counter(
|
||||||
|
"pip_reservation_operations_total",
|
||||||
|
"Reservation operations",
|
||||||
|
["operation", "status"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def metrics_middleware(request: Request, call_next: Callable) -> Response:
|
||||||
|
if request.url.path == "/metrics" or request.url.path.startswith("/docs") or request.url.path.startswith("/openapi"):
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
method = request.method
|
||||||
|
endpoint = request.url.path
|
||||||
|
|
||||||
|
REQUESTS_IN_PROGRESS.labels(method=method, endpoint=endpoint).inc()
|
||||||
|
|
||||||
|
start_time = time.perf_counter()
|
||||||
|
try:
|
||||||
|
response = await call_next(request)
|
||||||
|
status_code = response.status_code
|
||||||
|
except Exception:
|
||||||
|
status_code = 500
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
duration = time.perf_counter() - start_time
|
||||||
|
REQUEST_COUNT.labels(method=method, endpoint=endpoint, status_code=status_code).inc()
|
||||||
|
REQUEST_DURATION.labels(method=method, endpoint=endpoint).observe(duration)
|
||||||
|
REQUESTS_IN_PROGRESS.labels(method=method, endpoint=endpoint).dec()
|
||||||
|
|
||||||
|
return response
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from opentelemetry import trace
|
||||||
|
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
|
||||||
|
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
|
||||||
|
from opentelemetry.instrumentation.redis import RedisInstrumentor
|
||||||
|
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
|
||||||
|
from opentelemetry.sdk.resources import Resource
|
||||||
|
from opentelemetry.sdk.trace import TracerProvider
|
||||||
|
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
||||||
|
|
||||||
|
from src.infrastructure.config.settings import settings
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||||
|
|
||||||
|
|
||||||
|
def setup_tracing(app: Any = None, engine: AsyncEngine | None = None) -> None:
|
||||||
|
if not settings.OTEL_TRACES_ENABLED:
|
||||||
|
return
|
||||||
|
|
||||||
|
resource = Resource.create({"service.name": settings.OTEL_SERVICE_NAME})
|
||||||
|
|
||||||
|
provider = TracerProvider(resource=resource)
|
||||||
|
|
||||||
|
otlp_exporter = OTLPSpanExporter(endpoint=settings.OTEL_EXPORTER_OTLP_ENDPOINT)
|
||||||
|
provider.add_span_processor(BatchSpanProcessor(otlp_exporter))
|
||||||
|
|
||||||
|
trace.set_tracer_provider(provider)
|
||||||
|
|
||||||
|
if engine is not None:
|
||||||
|
sync_engine = engine.sync_engine if hasattr(engine, "sync_engine") else engine
|
||||||
|
SQLAlchemyInstrumentor().instrument(engine=sync_engine)
|
||||||
|
RedisInstrumentor().instrument()
|
||||||
|
|
||||||
|
if app is not None:
|
||||||
|
FastAPIInstrumentor.instrument_app(app)
|
||||||
|
|
||||||
|
|
||||||
|
def get_tracer(name: str = "pip-platform") -> trace.Tracer:
|
||||||
|
return trace.get_tracer(name)
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
from .entities import (
|
||||||
|
APIClientEntity,
|
||||||
|
APIKeyEntity,
|
||||||
|
AuditLogEntity,
|
||||||
|
ConnectorConfigEntity,
|
||||||
|
ConnectorEntity,
|
||||||
|
ConnectorStatus,
|
||||||
|
ConnectorVersionEntity,
|
||||||
|
ERPType,
|
||||||
|
ERPSystemEntity,
|
||||||
|
InventoryHistoryEntity,
|
||||||
|
InventoryRecordEntity,
|
||||||
|
MedicationCatalogEntryEntity,
|
||||||
|
MedicationEntity,
|
||||||
|
PharmacyEntity,
|
||||||
|
PharmacyStatus,
|
||||||
|
ReservationEntity,
|
||||||
|
ReservationStatus,
|
||||||
|
SyncJobEntity,
|
||||||
|
SyncJobStatus,
|
||||||
|
SyncLogEntity,
|
||||||
|
SyncStrategy,
|
||||||
|
TechnicalUserEntity,
|
||||||
|
UserRole,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"APIClientEntity",
|
||||||
|
"APIKeyEntity",
|
||||||
|
"AuditLogEntity",
|
||||||
|
"ConnectorConfigEntity",
|
||||||
|
"ConnectorEntity",
|
||||||
|
"ConnectorStatus",
|
||||||
|
"ConnectorVersionEntity",
|
||||||
|
"ERPType",
|
||||||
|
"ERPSystemEntity",
|
||||||
|
"InventoryHistoryEntity",
|
||||||
|
"InventoryRecordEntity",
|
||||||
|
"MedicationCatalogEntryEntity",
|
||||||
|
"MedicationEntity",
|
||||||
|
"PharmacyEntity",
|
||||||
|
"PharmacyStatus",
|
||||||
|
"ReservationEntity",
|
||||||
|
"ReservationStatus",
|
||||||
|
"SyncJobEntity",
|
||||||
|
"SyncJobStatus",
|
||||||
|
"SyncLogEntity",
|
||||||
|
"SyncStrategy",
|
||||||
|
"TechnicalUserEntity",
|
||||||
|
"UserRole",
|
||||||
|
]
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class PharmacyStatus(str, Enum):
|
||||||
|
ACTIVE = "active"
|
||||||
|
INACTIVE = "inactive"
|
||||||
|
SUSPENDED = "suspended"
|
||||||
|
PENDING = "pending"
|
||||||
|
|
||||||
|
|
||||||
|
class ERPType(str, Enum):
|
||||||
|
FARMATIC = "farmatic"
|
||||||
|
NIXFARMA = "nixfarma"
|
||||||
|
UNYCOP = "unycop"
|
||||||
|
CUSTOM_REST = "custom_rest"
|
||||||
|
CUSTOM_SOAP = "custom_soap"
|
||||||
|
CSV = "csv"
|
||||||
|
FTP = "ftp"
|
||||||
|
SQL = "sql"
|
||||||
|
MOCK = "mock"
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectorStatus(str, Enum):
|
||||||
|
ACTIVE = "active"
|
||||||
|
INACTIVE = "inactive"
|
||||||
|
ERROR = "error"
|
||||||
|
PENDING = "pending"
|
||||||
|
UPDATING = "updating"
|
||||||
|
|
||||||
|
|
||||||
|
class SyncStrategy(str, Enum):
|
||||||
|
FULL = "full"
|
||||||
|
INCREMENTAL = "incremental"
|
||||||
|
EVENT_DRIVEN = "event_driven"
|
||||||
|
SCHEDULED = "scheduled"
|
||||||
|
HYBRID = "hybrid"
|
||||||
|
|
||||||
|
|
||||||
|
class SyncJobStatus(str, Enum):
|
||||||
|
PENDING = "pending"
|
||||||
|
RUNNING = "running"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
FAILED = "failed"
|
||||||
|
CANCELLED = "cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
class ReservationStatus(str, Enum):
|
||||||
|
PENDING = "pending"
|
||||||
|
CONFIRMED = "confirmed"
|
||||||
|
CANCELLED = "cancelled"
|
||||||
|
EXPIRED = "expired"
|
||||||
|
|
||||||
|
|
||||||
|
class UserRole(str, Enum):
|
||||||
|
SUPERADMIN = "superadmin"
|
||||||
|
ADMIN = "admin"
|
||||||
|
OPERATOR = "operator"
|
||||||
|
VIEWER = "viewer"
|
||||||
|
API_CLIENT = "api_client"
|
||||||
|
|
||||||
|
|
||||||
|
class PharmacyEntity(BaseModel):
|
||||||
|
id: uuid.UUID | None = None
|
||||||
|
code: str
|
||||||
|
name: str
|
||||||
|
address: str | None = None
|
||||||
|
city: str | None = None
|
||||||
|
province: str | None = None
|
||||||
|
postal_code: str | None = None
|
||||||
|
country: str = "ES"
|
||||||
|
latitude: float | None = None
|
||||||
|
longitude: float | None = None
|
||||||
|
phone: str | None = None
|
||||||
|
email: str | None = None
|
||||||
|
opening_hours: dict | None = None
|
||||||
|
status: PharmacyStatus = PharmacyStatus.PENDING
|
||||||
|
erp_type: ERPType | None = None
|
||||||
|
erp_version: str | None = None
|
||||||
|
connector_id: uuid.UUID | None = None
|
||||||
|
sync_strategy: SyncStrategy = SyncStrategy.INCREMENTAL
|
||||||
|
sync_interval_minutes: int = 30
|
||||||
|
last_sync_at: datetime | None = None
|
||||||
|
last_sync_status: SyncJobStatus | None = None
|
||||||
|
metadata_: dict | None = Field(default=None, alias="metadata")
|
||||||
|
created_at: datetime | None = None
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
|
||||||
|
model_config = {"populate_by_name": True}
|
||||||
|
|
||||||
|
|
||||||
|
class ERPSystemEntity(BaseModel):
|
||||||
|
id: uuid.UUID | None = None
|
||||||
|
name: str
|
||||||
|
erp_type: ERPType
|
||||||
|
version: str | None = None
|
||||||
|
base_url: str | None = None
|
||||||
|
auth_config: dict | None = None
|
||||||
|
status: ConnectorStatus = ConnectorStatus.PENDING
|
||||||
|
metadata_: dict | None = Field(default=None, alias="metadata")
|
||||||
|
created_at: datetime | None = None
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
|
||||||
|
model_config = {"populate_by_name": True}
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectorEntity(BaseModel):
|
||||||
|
id: uuid.UUID | None = None
|
||||||
|
erp_system_id: uuid.UUID
|
||||||
|
pharmacy_id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
connector_type: ERPType
|
||||||
|
version: str = "1.0.0"
|
||||||
|
config: dict | None = None
|
||||||
|
status: ConnectorStatus = ConnectorStatus.PENDING
|
||||||
|
last_heartbeat_at: datetime | None = None
|
||||||
|
last_error: str | None = None
|
||||||
|
metadata_: dict | None = Field(default=None, alias="metadata")
|
||||||
|
created_at: datetime | None = None
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
|
||||||
|
model_config = {"populate_by_name": True}
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectorVersionEntity(BaseModel):
|
||||||
|
id: uuid.UUID | None = None
|
||||||
|
connector_id: uuid.UUID | None = None
|
||||||
|
connector_type: ERPType
|
||||||
|
version: str
|
||||||
|
changelog: str | None = None
|
||||||
|
download_url: str | None = None
|
||||||
|
checksum: str | None = None
|
||||||
|
is_latest: bool = False
|
||||||
|
is_required: bool = False
|
||||||
|
released_at: datetime | None = None
|
||||||
|
created_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectorConfigEntity(BaseModel):
|
||||||
|
id: uuid.UUID | None = None
|
||||||
|
connector_id: uuid.UUID
|
||||||
|
key: str
|
||||||
|
value: str
|
||||||
|
encrypted: bool = False
|
||||||
|
description: str | None = None
|
||||||
|
created_at: datetime | None = None
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class MedicationEntity(BaseModel):
|
||||||
|
id: uuid.UUID | None = None
|
||||||
|
nregistro: str
|
||||||
|
name: str
|
||||||
|
active_ingredient: str | None = None
|
||||||
|
dosage: str | None = None
|
||||||
|
form: str | None = None
|
||||||
|
atc_code: str | None = None
|
||||||
|
prescription_required: bool = False
|
||||||
|
manufacturer: str | None = None
|
||||||
|
metadata_: dict | None = Field(default=None, alias="metadata")
|
||||||
|
created_at: datetime | None = None
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
|
||||||
|
model_config = {"populate_by_name": True}
|
||||||
|
|
||||||
|
|
||||||
|
class MedicationCatalogEntryEntity(BaseModel):
|
||||||
|
id: uuid.UUID | None = None
|
||||||
|
pharmacy_id: uuid.UUID
|
||||||
|
medication_id: uuid.UUID
|
||||||
|
price: float | None = None
|
||||||
|
stock: int = 0
|
||||||
|
is_available: bool = False
|
||||||
|
last_verified_at: datetime | None = None
|
||||||
|
metadata_: dict | None = Field(default=None, alias="metadata")
|
||||||
|
created_at: datetime | None = None
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
|
||||||
|
model_config = {"populate_by_name": True}
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryRecordEntity(BaseModel):
|
||||||
|
id: uuid.UUID | None = None
|
||||||
|
pharmacy_id: uuid.UUID
|
||||||
|
medication_id: uuid.UUID
|
||||||
|
stock: int = 0
|
||||||
|
reserved_stock: int = 0
|
||||||
|
available_stock: int = 0
|
||||||
|
min_stock: int | None = None
|
||||||
|
max_stock: int | None = None
|
||||||
|
last_updated_at: datetime | None = None
|
||||||
|
source: str | None = None
|
||||||
|
batch_id: str | None = None
|
||||||
|
metadata_: dict | None = Field(default=None, alias="metadata")
|
||||||
|
created_at: datetime | None = None
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
|
||||||
|
model_config = {"populate_by_name": True}
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryHistoryEntity(BaseModel):
|
||||||
|
id: uuid.UUID | None = None
|
||||||
|
inventory_record_id: uuid.UUID | None = None
|
||||||
|
pharmacy_id: uuid.UUID
|
||||||
|
medication_id: uuid.UUID
|
||||||
|
previous_stock: int
|
||||||
|
new_stock: int
|
||||||
|
delta: int
|
||||||
|
reason: str | None = None
|
||||||
|
source: str | None = None
|
||||||
|
created_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ReservationEntity(BaseModel):
|
||||||
|
id: uuid.UUID | None = None
|
||||||
|
pharmacy_id: uuid.UUID
|
||||||
|
medication_id: uuid.UUID
|
||||||
|
user_id: uuid.UUID | None = None
|
||||||
|
external_reference: str | None = None
|
||||||
|
quantity: int
|
||||||
|
status: ReservationStatus = ReservationStatus.PENDING
|
||||||
|
expires_at: datetime | None = None
|
||||||
|
confirmed_at: datetime | None = None
|
||||||
|
cancelled_at: datetime | None = None
|
||||||
|
idempotency_key: str | None = None
|
||||||
|
metadata_: dict | None = Field(default=None, alias="metadata")
|
||||||
|
created_at: datetime | None = None
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
|
||||||
|
model_config = {"populate_by_name": True}
|
||||||
|
|
||||||
|
|
||||||
|
class SyncJobEntity(BaseModel):
|
||||||
|
id: uuid.UUID | None = None
|
||||||
|
pharmacy_id: uuid.UUID
|
||||||
|
connector_id: uuid.UUID
|
||||||
|
strategy: SyncStrategy
|
||||||
|
status: SyncJobStatus = SyncJobStatus.PENDING
|
||||||
|
started_at: datetime | None = None
|
||||||
|
completed_at: datetime | None = None
|
||||||
|
items_total: int | None = None
|
||||||
|
items_processed: int | None = None
|
||||||
|
items_failed: int | None = None
|
||||||
|
error_message: str | None = None
|
||||||
|
metadata_: dict | None = Field(default=None, alias="metadata")
|
||||||
|
created_at: datetime | None = None
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
|
||||||
|
model_config = {"populate_by_name": True}
|
||||||
|
|
||||||
|
|
||||||
|
class SyncLogEntity(BaseModel):
|
||||||
|
id: uuid.UUID | None = None
|
||||||
|
sync_job_id: uuid.UUID
|
||||||
|
level: str = "INFO"
|
||||||
|
message: str
|
||||||
|
details: dict | None = None
|
||||||
|
created_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLogEntity(BaseModel):
|
||||||
|
id: uuid.UUID | None = None
|
||||||
|
actor_id: uuid.UUID | None = None
|
||||||
|
actor_type: str | None = None
|
||||||
|
action: str
|
||||||
|
resource_type: str
|
||||||
|
resource_id: str | None = None
|
||||||
|
old_value: dict | None = None
|
||||||
|
new_value: dict | None = None
|
||||||
|
ip_address: str | None = None
|
||||||
|
user_agent: str | None = None
|
||||||
|
request_id: str | None = None
|
||||||
|
created_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TechnicalUserEntity(BaseModel):
|
||||||
|
id: uuid.UUID | None = None
|
||||||
|
username: str
|
||||||
|
email: str | None = None
|
||||||
|
full_name: str | None = None
|
||||||
|
role: UserRole = UserRole.VIEWER
|
||||||
|
is_active: bool = True
|
||||||
|
scopes: list[str] = Field(default_factory=list)
|
||||||
|
last_login_at: datetime | None = None
|
||||||
|
created_at: datetime | None = None
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class APIClientEntity(BaseModel):
|
||||||
|
id: uuid.UUID | None = None
|
||||||
|
name: str
|
||||||
|
description: str | None = None
|
||||||
|
owner_id: uuid.UUID | None = None
|
||||||
|
is_active: bool = True
|
||||||
|
scopes: list[str] = Field(default_factory=list)
|
||||||
|
rate_limit: int | None = None
|
||||||
|
allowed_ips: list[str] = Field(default_factory=list)
|
||||||
|
metadata_: dict | None = Field(default=None, alias="metadata")
|
||||||
|
created_at: datetime | None = None
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
|
||||||
|
model_config = {"populate_by_name": True}
|
||||||
|
|
||||||
|
|
||||||
|
class APIKeyEntity(BaseModel):
|
||||||
|
id: uuid.UUID | None = None
|
||||||
|
api_client_id: uuid.UUID
|
||||||
|
key_prefix: str
|
||||||
|
key_hash: bytes
|
||||||
|
name: str
|
||||||
|
is_active: bool = True
|
||||||
|
expires_at: datetime | None = None
|
||||||
|
last_used_at: datetime | None = None
|
||||||
|
created_at: datetime | None = None
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
from .repositories import (
|
||||||
|
IAPIClientRepository,
|
||||||
|
IAPIKeyRepository,
|
||||||
|
IAuditLogRepository,
|
||||||
|
IConnectorConfigRepository,
|
||||||
|
IConnectorRepository,
|
||||||
|
IConnectorVersionRepository,
|
||||||
|
IERPSystemRepository,
|
||||||
|
IInventoryRepository,
|
||||||
|
IMedicationCatalogRepository,
|
||||||
|
IMedicationRepository,
|
||||||
|
IPharmacyRepository,
|
||||||
|
IReservationRepository,
|
||||||
|
ISyncJobRepository,
|
||||||
|
ISyncLogRepository,
|
||||||
|
ITechnicalUserRepository,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"IAPIClientRepository",
|
||||||
|
"IAPIKeyRepository",
|
||||||
|
"IAuditLogRepository",
|
||||||
|
"IConnectorConfigRepository",
|
||||||
|
"IConnectorRepository",
|
||||||
|
"IConnectorVersionRepository",
|
||||||
|
"IERPSystemRepository",
|
||||||
|
"IInventoryRepository",
|
||||||
|
"IMedicationCatalogRepository",
|
||||||
|
"IMedicationRepository",
|
||||||
|
"IPharmacyRepository",
|
||||||
|
"IReservationRepository",
|
||||||
|
"ISyncJobRepository",
|
||||||
|
"ISyncLogRepository",
|
||||||
|
"ITechnicalUserRepository",
|
||||||
|
]
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
import uuid
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from src.domain.entities import (
|
||||||
|
APIClientEntity,
|
||||||
|
APIKeyEntity,
|
||||||
|
AuditLogEntity,
|
||||||
|
ConnectorConfigEntity,
|
||||||
|
ConnectorEntity,
|
||||||
|
ConnectorVersionEntity,
|
||||||
|
ERPSystemEntity,
|
||||||
|
InventoryHistoryEntity,
|
||||||
|
InventoryRecordEntity,
|
||||||
|
MedicationCatalogEntryEntity,
|
||||||
|
MedicationEntity,
|
||||||
|
PharmacyEntity,
|
||||||
|
ReservationEntity,
|
||||||
|
SyncJobEntity,
|
||||||
|
SyncLogEntity,
|
||||||
|
TechnicalUserEntity,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class IPharmacyRepository(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
async def get_by_id(self, pharmacy_id: uuid.UUID) -> PharmacyEntity | None: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_by_code(self, code: str) -> PharmacyEntity | None: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def list_pharmacies(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
status: str | None = None,
|
||||||
|
erp_type: str | None = None,
|
||||||
|
offset: int = 0,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> tuple[Sequence[PharmacyEntity], int]: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def create(self, entity: PharmacyEntity) -> PharmacyEntity: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def update(self, entity: PharmacyEntity) -> PharmacyEntity: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def delete(self, pharmacy_id: uuid.UUID) -> bool: ...
|
||||||
|
|
||||||
|
|
||||||
|
class IERPSystemRepository(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
async def get_by_id(self, erp_id: uuid.UUID) -> ERPSystemEntity | None: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def list_erp_systems(self, *, erp_type: str | None = None, offset: int = 0, limit: int = 50) -> tuple[Sequence[ERPSystemEntity], int]: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def create(self, entity: ERPSystemEntity) -> ERPSystemEntity: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def update(self, entity: ERPSystemEntity) -> ERPSystemEntity: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def delete(self, erp_id: uuid.UUID) -> bool: ...
|
||||||
|
|
||||||
|
|
||||||
|
class IConnectorRepository(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
async def get_by_id(self, connector_id: uuid.UUID) -> ConnectorEntity | None: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def list_connectors(self, *, erp_system_id: uuid.UUID | None = None, pharmacy_id: uuid.UUID | None = None, status: str | None = None, offset: int = 0, limit: int = 50) -> tuple[Sequence[ConnectorEntity], int]: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def create(self, entity: ConnectorEntity) -> ConnectorEntity: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def update(self, entity: ConnectorEntity) -> ConnectorEntity: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def delete(self, connector_id: uuid.UUID) -> bool: ...
|
||||||
|
|
||||||
|
|
||||||
|
class IConnectorVersionRepository(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
async def get_latest(self, connector_type: str) -> ConnectorVersionEntity | None: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def list_versions(self, *, connector_type: str | None = None, offset: int = 0, limit: int = 50) -> tuple[Sequence[ConnectorVersionEntity], int]: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def create(self, entity: ConnectorVersionEntity) -> ConnectorVersionEntity: ...
|
||||||
|
|
||||||
|
|
||||||
|
class IConnectorConfigRepository(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
async def list_by_connector(self, connector_id: uuid.UUID) -> Sequence[ConnectorConfigEntity]: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_by_key(self, connector_id: uuid.UUID, key: str) -> ConnectorConfigEntity | None: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def upsert(self, entity: ConnectorConfigEntity) -> ConnectorConfigEntity: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def delete(self, config_id: uuid.UUID) -> bool: ...
|
||||||
|
|
||||||
|
|
||||||
|
class IMedicationRepository(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
async def get_by_id(self, medication_id: uuid.UUID) -> MedicationEntity | None: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_by_nregistro(self, nregistro: str) -> MedicationEntity | None: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def search(self, query: str, *, offset: int = 0, limit: int = 50) -> tuple[Sequence[MedicationEntity], int]: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def create(self, entity: MedicationEntity) -> MedicationEntity: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def update(self, entity: MedicationEntity) -> MedicationEntity: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def delete(self, medication_id: uuid.UUID) -> bool: ...
|
||||||
|
|
||||||
|
|
||||||
|
class IMedicationCatalogRepository(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
async def get_entry(self, pharmacy_id: uuid.UUID, medication_id: uuid.UUID) -> MedicationCatalogEntryEntity | None: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def list_by_pharmacy(self, pharmacy_id: uuid.UUID, *, offset: int = 0, limit: int = 50) -> tuple[Sequence[MedicationCatalogEntryEntity], int]: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def upsert(self, entity: MedicationCatalogEntryEntity) -> MedicationCatalogEntryEntity: ...
|
||||||
|
|
||||||
|
|
||||||
|
class IInventoryRepository(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
async def get_record(self, pharmacy_id: uuid.UUID, medication_id: uuid.UUID) -> InventoryRecordEntity | None: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def list_by_pharmacy(self, pharmacy_id: uuid.UUID, *, offset: int = 0, limit: int = 50) -> tuple[Sequence[InventoryRecordEntity], int]: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def upsert(self, entity: InventoryRecordEntity) -> InventoryRecordEntity: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def add_history(self, entity: InventoryHistoryEntity) -> InventoryHistoryEntity: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_history(self, pharmacy_id: uuid.UUID, medication_id: uuid.UUID, *, offset: int = 0, limit: int = 50) -> Sequence[InventoryHistoryEntity]: ...
|
||||||
|
|
||||||
|
|
||||||
|
class IReservationRepository(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
async def get_by_id(self, reservation_id: uuid.UUID) -> ReservationEntity | None: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_by_idempotency_key(self, key: str) -> ReservationEntity | None: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def list_by_pharmacy(self, pharmacy_id: uuid.UUID, *, status: str | None = None, offset: int = 0, limit: int = 50) -> tuple[Sequence[ReservationEntity], int]: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def create(self, entity: ReservationEntity) -> ReservationEntity: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def update(self, entity: ReservationEntity) -> ReservationEntity: ...
|
||||||
|
|
||||||
|
|
||||||
|
class ISyncJobRepository(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
async def get_by_id(self, job_id: uuid.UUID) -> SyncJobEntity | None: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def list_by_pharmacy(self, pharmacy_id: uuid.UUID, *, status: str | None = None, offset: int = 0, limit: int = 50) -> tuple[Sequence[SyncJobEntity], int]: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def create(self, entity: SyncJobEntity) -> SyncJobEntity: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def update(self, entity: SyncJobEntity) -> SyncJobEntity: ...
|
||||||
|
|
||||||
|
|
||||||
|
class ISyncLogRepository(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
async def add(self, entity: SyncLogEntity) -> SyncLogEntity: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def list_by_job(self, job_id: uuid.UUID, *, level: str | None = None, offset: int = 0, limit: int = 100) -> Sequence[SyncLogEntity]: ...
|
||||||
|
|
||||||
|
|
||||||
|
class IAuditLogRepository(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
async def add(self, entity: AuditLogEntity) -> AuditLogEntity: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def list_logs(self, *, actor_id: uuid.UUID | None = None, action: str | None = None, resource_type: str | None = None, offset: int = 0, limit: int = 50) -> tuple[Sequence[AuditLogEntity], int]: ...
|
||||||
|
|
||||||
|
|
||||||
|
class ITechnicalUserRepository(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
async def get_by_id(self, user_id: uuid.UUID) -> TechnicalUserEntity | None: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_by_username(self, username: str) -> TechnicalUserEntity | None: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def list_users(self, *, role: str | None = None, is_active: bool | None = None, offset: int = 0, limit: int = 50) -> tuple[Sequence[TechnicalUserEntity], int]: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def create(self, entity: TechnicalUserEntity, password_hash: str) -> TechnicalUserEntity: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def update(self, entity: TechnicalUserEntity) -> TechnicalUserEntity: ...
|
||||||
|
|
||||||
|
|
||||||
|
class IAPIClientRepository(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
async def get_by_id(self, client_id: uuid.UUID) -> APIClientEntity | None: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def list_clients(self, *, is_active: bool | None = None, offset: int = 0, limit: int = 50) -> tuple[Sequence[APIClientEntity], int]: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def create(self, entity: APIClientEntity) -> APIClientEntity: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def update(self, entity: APIClientEntity) -> APIClientEntity: ...
|
||||||
|
|
||||||
|
|
||||||
|
class IAPIKeyRepository(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
async def get_by_prefix(self, key_prefix: str) -> APIKeyEntity | None: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def list_by_client(self, api_client_id: uuid.UUID) -> Sequence[APIKeyEntity]: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def create(self, entity: APIKeyEntity) -> APIKeyEntity: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def update_last_used(self, key_id: uuid.UUID) -> None: ...
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from .redis_cache import redis_cache
|
||||||
|
|
||||||
|
__all__ = ["redis_cache"]
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import redis.asyncio as aioredis
|
||||||
|
|
||||||
|
from src.infrastructure.config.settings import settings
|
||||||
|
|
||||||
|
|
||||||
|
class RedisCache:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._client: aioredis.Redis | None = None
|
||||||
|
|
||||||
|
async def connect(self) -> None:
|
||||||
|
self._client = aioredis.from_url(
|
||||||
|
settings.REDIS_URL,
|
||||||
|
decode_responses=True,
|
||||||
|
max_connections=20,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def disconnect(self) -> None:
|
||||||
|
if self._client:
|
||||||
|
await self._client.close()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def client(self) -> aioredis.Redis:
|
||||||
|
if self._client is None:
|
||||||
|
raise RuntimeError("Redis client not connected. Call connect() first.")
|
||||||
|
return self._client
|
||||||
|
|
||||||
|
async def get(self, key: str) -> Any | None:
|
||||||
|
data = await self.client.get(key)
|
||||||
|
if data is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(data)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return data
|
||||||
|
|
||||||
|
async def set(self, key: str, value: Any, ttl: int | None = None) -> None:
|
||||||
|
ttl = ttl or settings.REDIS_CACHE_TTL
|
||||||
|
if isinstance(value, (dict, list, tuple)):
|
||||||
|
value = json.dumps(value)
|
||||||
|
await self.client.set(key, value, ex=ttl)
|
||||||
|
|
||||||
|
async def delete(self, key: str) -> None:
|
||||||
|
await self.client.delete(key)
|
||||||
|
|
||||||
|
async def exists(self, key: str) -> bool:
|
||||||
|
return bool(await self.client.exists(key))
|
||||||
|
|
||||||
|
async def get_with_lock(self, key: str, lock_timeout: int = 10) -> Any | None:
|
||||||
|
async with self.client.lock(f"{key}:lock", timeout=lock_timeout):
|
||||||
|
return await self.get(key)
|
||||||
|
|
||||||
|
async def set_with_lock(self, key: str, value: Any, ttl: int | None = None, lock_timeout: int = 10) -> None:
|
||||||
|
async with self.client.lock(f"{key}:lock", timeout=lock_timeout):
|
||||||
|
await self.set(key, value, ttl)
|
||||||
|
|
||||||
|
async def invalidate_pattern(self, pattern: str) -> None:
|
||||||
|
keys = []
|
||||||
|
async for key in self.client.scan_iter(match=pattern):
|
||||||
|
keys.append(key)
|
||||||
|
if keys:
|
||||||
|
await self.client.delete(*keys)
|
||||||
|
|
||||||
|
async def health_check(self) -> bool:
|
||||||
|
try:
|
||||||
|
return await self.client.ping()
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
redis_cache = RedisCache()
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=".env",
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
env_nested_delimiter="__",
|
||||||
|
extra="ignore",
|
||||||
|
)
|
||||||
|
|
||||||
|
APP_NAME: str = "PIP - Pharmacy Integration Platform"
|
||||||
|
APP_VERSION: str = "0.1.0"
|
||||||
|
DEBUG: bool = False
|
||||||
|
|
||||||
|
DATABASE_URL: str = "postgresql+asyncpg://pip:pip@localhost:5432/pip"
|
||||||
|
DATABASE_POOL_SIZE: int = 20
|
||||||
|
DATABASE_MAX_OVERFLOW: int = 10
|
||||||
|
DATABASE_POOL_RECYCLE: int = 3600
|
||||||
|
|
||||||
|
REDIS_URL: str = "redis://localhost:6379/0"
|
||||||
|
REDIS_CACHE_TTL: int = 300
|
||||||
|
|
||||||
|
RABBITMQ_URL: str = "amqp://pip:pip@localhost:5672/pip"
|
||||||
|
|
||||||
|
JWT_SECRET_KEY: str = "change-me-in-production"
|
||||||
|
JWT_ALGORITHM: str = "HS256"
|
||||||
|
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||||
|
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
||||||
|
JWT_ISSUER: str = "pip-platform"
|
||||||
|
|
||||||
|
OAUTH2_TOKEN_URL: str = "/api/v1/auth/token"
|
||||||
|
|
||||||
|
RATE_LIMIT_PER_MINUTE: int = 60
|
||||||
|
RATE_LIMIT_AUTH_PER_MINUTE: int = 10
|
||||||
|
|
||||||
|
API_KEY_PREFIX: str = "pip"
|
||||||
|
API_KEY_LENGTH: int = 32
|
||||||
|
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT: str = "http://localhost:4317"
|
||||||
|
OTEL_SERVICE_NAME: str = "pip-platform"
|
||||||
|
OTEL_TRACES_ENABLED: bool = True
|
||||||
|
OTEL_METRICS_ENABLED: bool = True
|
||||||
|
|
||||||
|
LOG_LEVEL: str = "INFO"
|
||||||
|
LOG_JSON_FORMAT: bool = True
|
||||||
|
|
||||||
|
CORS_ORIGINS: list[str] = ["*"]
|
||||||
|
CORS_ALLOW_CREDENTIALS: bool = True
|
||||||
|
|
||||||
|
HEALTH_CHECK_CACHE_TTL: int = 10
|
||||||
|
|
||||||
|
@property
|
||||||
|
def DATABASE_URL_SYNC(self) -> str:
|
||||||
|
return self.DATABASE_URL.replace("+asyncpg", "+psycopg2", 1)
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from .models import *
|
||||||
|
from .session import async_session_factory, engine, get_db_session, get_db_session_no_commit
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"async_session_factory",
|
||||||
|
"engine",
|
||||||
|
"get_db_session",
|
||||||
|
"get_db_session_no_commit",
|
||||||
|
]
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
from .models import (
|
||||||
|
APIClientModel,
|
||||||
|
APIKeyModel,
|
||||||
|
AuditLogModel,
|
||||||
|
Base,
|
||||||
|
BaseMixin,
|
||||||
|
ConnectorConfigModel,
|
||||||
|
ConnectorModel,
|
||||||
|
ConnectorVersionModel,
|
||||||
|
ERPSystemModel,
|
||||||
|
InventoryHistoryModel,
|
||||||
|
InventoryRecordModel,
|
||||||
|
MedicationCatalogEntryModel,
|
||||||
|
MedicationModel,
|
||||||
|
PharmacyModel,
|
||||||
|
ReservationModel,
|
||||||
|
SyncJobModel,
|
||||||
|
SyncLogModel,
|
||||||
|
TechnicalUserModel,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"APIClientModel",
|
||||||
|
"APIKeyModel",
|
||||||
|
"AuditLogModel",
|
||||||
|
"Base",
|
||||||
|
"BaseMixin",
|
||||||
|
"ConnectorConfigModel",
|
||||||
|
"ConnectorModel",
|
||||||
|
"ConnectorVersionModel",
|
||||||
|
"ERPSystemModel",
|
||||||
|
"InventoryHistoryModel",
|
||||||
|
"InventoryRecordModel",
|
||||||
|
"MedicationCatalogEntryModel",
|
||||||
|
"MedicationModel",
|
||||||
|
"PharmacyModel",
|
||||||
|
"ReservationModel",
|
||||||
|
"SyncJobModel",
|
||||||
|
"SyncLogModel",
|
||||||
|
"TechnicalUserModel",
|
||||||
|
]
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
Boolean,
|
||||||
|
DateTime,
|
||||||
|
Enum,
|
||||||
|
Float,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
LargeBinary,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
UniqueConstraint,
|
||||||
|
func,
|
||||||
|
)
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class BaseMixin:
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class PharmacyModel(Base, BaseMixin):
|
||||||
|
__tablename__ = "pharmacies"
|
||||||
|
|
||||||
|
code: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
address: Mapped[str | None] = mapped_column(String(500))
|
||||||
|
city: Mapped[str | None] = mapped_column(String(100))
|
||||||
|
province: Mapped[str | None] = mapped_column(String(100))
|
||||||
|
postal_code: Mapped[str | None] = mapped_column(String(20))
|
||||||
|
country: Mapped[str] = mapped_column(String(2), default="ES")
|
||||||
|
latitude: Mapped[float | None] = mapped_column(Float)
|
||||||
|
longitude: Mapped[float | None] = mapped_column(Float)
|
||||||
|
phone: Mapped[str | None] = mapped_column(String(50))
|
||||||
|
email: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
opening_hours: Mapped[dict | None] = mapped_column(JSONB)
|
||||||
|
status: Mapped[str] = mapped_column(String(20), default="pending", index=True)
|
||||||
|
erp_type: Mapped[str | None] = mapped_column(String(50))
|
||||||
|
erp_version: Mapped[str | None] = mapped_column(String(50))
|
||||||
|
connector_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("connectors.id"), nullable=True)
|
||||||
|
sync_strategy: Mapped[str] = mapped_column(String(20), default="incremental")
|
||||||
|
sync_interval_minutes: Mapped[int] = mapped_column(Integer, default=30)
|
||||||
|
last_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
last_sync_status: Mapped[str | None] = mapped_column(String(20))
|
||||||
|
metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB)
|
||||||
|
|
||||||
|
connector = relationship("ConnectorModel", back_populates="pharmacies", foreign_keys=[connector_id])
|
||||||
|
inventory_records = relationship("InventoryRecordModel", back_populates="pharmacy", cascade="all, delete-orphan")
|
||||||
|
reservations = relationship("ReservationModel", back_populates="pharmacy", cascade="all, delete-orphan")
|
||||||
|
catalog_entries = relationship("MedicationCatalogEntryModel", back_populates="pharmacy", cascade="all, delete-orphan")
|
||||||
|
sync_jobs = relationship("SyncJobModel", back_populates="pharmacy", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_pharmacies_location", "latitude", "longitude"),
|
||||||
|
Index("ix_pharmacies_status_erp", "status", "erp_type"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ERPSystemModel(Base, BaseMixin):
|
||||||
|
__tablename__ = "erp_systems"
|
||||||
|
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
erp_type: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||||
|
version: Mapped[str | None] = mapped_column(String(50))
|
||||||
|
base_url: Mapped[str | None] = mapped_column(String(500))
|
||||||
|
auth_config: Mapped[dict | None] = mapped_column(JSONB)
|
||||||
|
status: Mapped[str] = mapped_column(String(20), default="pending", index=True)
|
||||||
|
metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB)
|
||||||
|
|
||||||
|
connectors = relationship("ConnectorModel", back_populates="erp_system", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectorModel(Base, BaseMixin):
|
||||||
|
__tablename__ = "connectors"
|
||||||
|
|
||||||
|
erp_system_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("erp_systems.id"), nullable=False, index=True)
|
||||||
|
pharmacy_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("pharmacies.id"), nullable=False, index=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
connector_type: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||||
|
version: Mapped[str] = mapped_column(String(20), default="1.0.0")
|
||||||
|
config: Mapped[dict | None] = mapped_column(JSONB)
|
||||||
|
status: Mapped[str] = mapped_column(String(20), default="pending", index=True)
|
||||||
|
last_heartbeat_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
last_error: Mapped[str | None] = mapped_column(Text)
|
||||||
|
metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB)
|
||||||
|
|
||||||
|
erp_system = relationship("ERPSystemModel", back_populates="connectors")
|
||||||
|
pharmacies = relationship("PharmacyModel", back_populates="connector", foreign_keys="PharmacyModel.connector_id")
|
||||||
|
configs = relationship("ConnectorConfigModel", back_populates="connector", cascade="all, delete-orphan")
|
||||||
|
connector_versions = relationship("ConnectorVersionModel", back_populates="connector", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("erp_system_id", "pharmacy_id", name="uq_connector_erp_pharmacy"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectorVersionModel(Base, BaseMixin):
|
||||||
|
__tablename__ = "connector_versions"
|
||||||
|
|
||||||
|
connector_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("connectors.id"), nullable=False, index=True)
|
||||||
|
connector_type: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||||
|
version: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||||
|
changelog: Mapped[str | None] = mapped_column(Text)
|
||||||
|
download_url: Mapped[str | None] = mapped_column(String(500))
|
||||||
|
checksum: Mapped[str | None] = mapped_column(String(128))
|
||||||
|
is_latest: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
|
||||||
|
is_required: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
released_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
connector = relationship("ConnectorModel", back_populates="connector_versions")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("connector_type", "version", name="uq_connector_version"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectorConfigModel(Base, BaseMixin):
|
||||||
|
__tablename__ = "connector_configs"
|
||||||
|
|
||||||
|
connector_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("connectors.id"), nullable=False, index=True)
|
||||||
|
key: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
value: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
encrypted: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(String(500))
|
||||||
|
|
||||||
|
connector = relationship("ConnectorModel", back_populates="configs")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("connector_id", "key", name="uq_connector_config_key"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MedicationModel(Base, BaseMixin):
|
||||||
|
__tablename__ = "medications"
|
||||||
|
|
||||||
|
nregistro: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(500), nullable=False, index=True)
|
||||||
|
active_ingredient: Mapped[str | None] = mapped_column(String(500))
|
||||||
|
dosage: Mapped[str | None] = mapped_column(String(100))
|
||||||
|
form: Mapped[str | None] = mapped_column(String(100))
|
||||||
|
atc_code: Mapped[str | None] = mapped_column(String(20), index=True)
|
||||||
|
prescription_required: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
manufacturer: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB)
|
||||||
|
|
||||||
|
catalog_entries = relationship("MedicationCatalogEntryModel", back_populates="medication", cascade="all, delete-orphan")
|
||||||
|
inventory_records = relationship("InventoryRecordModel", back_populates="medication", cascade="all, delete-orphan")
|
||||||
|
reservations = relationship("ReservationModel", back_populates="medication", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class MedicationCatalogEntryModel(Base, BaseMixin):
|
||||||
|
__tablename__ = "medication_catalog"
|
||||||
|
|
||||||
|
pharmacy_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("pharmacies.id"), nullable=False, index=True)
|
||||||
|
medication_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("medications.id"), nullable=False, index=True)
|
||||||
|
price: Mapped[float | None] = mapped_column(Float)
|
||||||
|
stock: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
is_available: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
|
||||||
|
last_verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB)
|
||||||
|
|
||||||
|
pharmacy = relationship("PharmacyModel", back_populates="catalog_entries")
|
||||||
|
medication = relationship("MedicationModel", back_populates="catalog_entries")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("pharmacy_id", "medication_id", name="uq_catalog_pharmacy_medication"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryRecordModel(Base, BaseMixin):
|
||||||
|
__tablename__ = "inventory_records"
|
||||||
|
|
||||||
|
pharmacy_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("pharmacies.id"), nullable=False, index=True)
|
||||||
|
medication_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("medications.id"), nullable=False, index=True)
|
||||||
|
stock: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
reserved_stock: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
available_stock: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
min_stock: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
max_stock: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
last_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
||||||
|
source: Mapped[str | None] = mapped_column(String(100))
|
||||||
|
batch_id: Mapped[str | None] = mapped_column(String(100))
|
||||||
|
metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB)
|
||||||
|
|
||||||
|
pharmacy = relationship("PharmacyModel", back_populates="inventory_records")
|
||||||
|
medication = relationship("MedicationModel", back_populates="inventory_records")
|
||||||
|
history = relationship("InventoryHistoryModel", back_populates="inventory_record", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("pharmacy_id", "medication_id", name="uq_inventory_pharmacy_medication"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryHistoryModel(Base):
|
||||||
|
__tablename__ = "inventory_history"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
inventory_record_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("inventory_records.id"), nullable=False, index=True)
|
||||||
|
pharmacy_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("pharmacies.id"), nullable=False, index=True)
|
||||||
|
medication_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("medications.id"), nullable=False, index=True)
|
||||||
|
previous_stock: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
new_stock: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
delta: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
reason: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
source: Mapped[str | None] = mapped_column(String(100))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True)
|
||||||
|
|
||||||
|
inventory_record = relationship("InventoryRecordModel", back_populates="history")
|
||||||
|
|
||||||
|
|
||||||
|
class ReservationModel(Base, BaseMixin):
|
||||||
|
__tablename__ = "reservations"
|
||||||
|
|
||||||
|
pharmacy_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("pharmacies.id"), nullable=False, index=True)
|
||||||
|
medication_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("medications.id"), nullable=False, index=True)
|
||||||
|
user_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("technical_users.id"), nullable=True, index=True)
|
||||||
|
external_reference: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
quantity: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
status: Mapped[str] = mapped_column(String(20), default="pending", index=True)
|
||||||
|
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
||||||
|
confirmed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
cancelled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
idempotency_key: Mapped[str | None] = mapped_column(String(255), unique=True, index=True)
|
||||||
|
metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB)
|
||||||
|
|
||||||
|
pharmacy = relationship("PharmacyModel", back_populates="reservations")
|
||||||
|
medication = relationship("MedicationModel", back_populates="reservations")
|
||||||
|
user = relationship("TechnicalUserModel", back_populates="reservations")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_reservations_pharmacy_status", "pharmacy_id", "status"),
|
||||||
|
Index("ix_reservations_expires", "expires_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SyncJobModel(Base, BaseMixin):
|
||||||
|
__tablename__ = "sync_jobs"
|
||||||
|
|
||||||
|
pharmacy_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("pharmacies.id"), nullable=False, index=True)
|
||||||
|
connector_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("connectors.id"), nullable=False, index=True)
|
||||||
|
strategy: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||||
|
status: Mapped[str] = mapped_column(String(20), default="pending", index=True)
|
||||||
|
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
items_total: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
items_processed: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
items_failed: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
error_message: Mapped[str | None] = mapped_column(Text)
|
||||||
|
metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB)
|
||||||
|
|
||||||
|
pharmacy = relationship("PharmacyModel", back_populates="sync_jobs")
|
||||||
|
logs = relationship("SyncLogModel", back_populates="sync_job", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class SyncLogModel(Base):
|
||||||
|
__tablename__ = "sync_logs"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
sync_job_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("sync_jobs.id"), nullable=False, index=True)
|
||||||
|
level: Mapped[str] = mapped_column(String(20), default="INFO", nullable=False)
|
||||||
|
message: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
details: Mapped[dict | None] = mapped_column(JSONB)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True)
|
||||||
|
|
||||||
|
sync_job = relationship("SyncJobModel", back_populates="logs")
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLogModel(Base):
|
||||||
|
__tablename__ = "audit_logs"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
actor_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), index=True)
|
||||||
|
actor_type: Mapped[str | None] = mapped_column(String(50))
|
||||||
|
action: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||||
|
resource_type: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||||
|
resource_id: Mapped[str | None] = mapped_column(String(255), index=True)
|
||||||
|
old_value: Mapped[dict | None] = mapped_column(JSONB)
|
||||||
|
new_value: Mapped[dict | None] = mapped_column(JSONB)
|
||||||
|
ip_address: Mapped[str | None] = mapped_column(String(45))
|
||||||
|
user_agent: Mapped[str | None] = mapped_column(String(500))
|
||||||
|
request_id: Mapped[str | None] = mapped_column(String(100), index=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_audit_logs_action_resource", "action", "resource_type"),
|
||||||
|
Index("ix_audit_logs_actor_created", "actor_id", "created_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TechnicalUserModel(Base, BaseMixin):
|
||||||
|
__tablename__ = "technical_users"
|
||||||
|
|
||||||
|
username: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
|
||||||
|
email: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
full_name: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
role: Mapped[str] = mapped_column(String(20), default="viewer", index=True)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
|
||||||
|
scopes: Mapped[dict | None] = mapped_column(JSONB, default=list)
|
||||||
|
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
reservations = relationship("ReservationModel", back_populates="user")
|
||||||
|
api_clients = relationship("APIClientModel", back_populates="owner")
|
||||||
|
|
||||||
|
|
||||||
|
class APIClientModel(Base, BaseMixin):
|
||||||
|
__tablename__ = "api_clients"
|
||||||
|
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
|
owner_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("technical_users.id"), nullable=True, index=True)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
|
||||||
|
scopes: Mapped[dict | None] = mapped_column(JSONB, default=list)
|
||||||
|
rate_limit: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
allowed_ips: Mapped[dict | None] = mapped_column(JSONB, default=list)
|
||||||
|
metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB)
|
||||||
|
|
||||||
|
owner = relationship("TechnicalUserModel", back_populates="api_clients")
|
||||||
|
api_keys = relationship("APIKeyModel", back_populates="api_client", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class APIKeyModel(Base):
|
||||||
|
__tablename__ = "api_keys"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
api_client_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("api_clients.id"), nullable=False, index=True)
|
||||||
|
key_prefix: Mapped[str] = mapped_column(String(10), nullable=False, index=True)
|
||||||
|
key_hash: Mapped[bytes] = mapped_column(LargeBinary(64), nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
|
||||||
|
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||||
|
|
||||||
|
api_client = relationship("APIClientModel", back_populates="api_keys")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_api_keys_prefix_active", "key_prefix", "is_active"),
|
||||||
|
)
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
from .pharmacy_repository import PharmacyRepository
|
||||||
|
from .technical_user_repository import TechnicalUserRepository
|
||||||
|
from .audit_log_repository import AuditLogRepository
|
||||||
|
from .api_client_repository import APIClientRepository, APIKeyRepository
|
||||||
|
from .connector_repository import ConnectorRepository
|
||||||
|
from .erp_system_repository import ERPSystemRepository
|
||||||
|
from .connector_config_repository import ConnectorConfigRepository
|
||||||
|
from .connector_version_repository import ConnectorVersionRepository
|
||||||
|
from .medication_repository import MedicationRepository
|
||||||
|
from .medication_catalog_repository import MedicationCatalogRepository
|
||||||
|
from .inventory_repository import InventoryRepository
|
||||||
|
from .reservation_repository import ReservationRepository
|
||||||
|
from .sync_job_repository import SyncJobRepository
|
||||||
|
from .sync_log_repository import SyncLogRepository
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"APIClientRepository",
|
||||||
|
"APIKeyRepository",
|
||||||
|
"AuditLogRepository",
|
||||||
|
"ConnectorConfigRepository",
|
||||||
|
"ConnectorRepository",
|
||||||
|
"ConnectorVersionRepository",
|
||||||
|
"ERPSystemRepository",
|
||||||
|
"InventoryRepository",
|
||||||
|
"MedicationCatalogRepository",
|
||||||
|
"MedicationRepository",
|
||||||
|
"PharmacyRepository",
|
||||||
|
"ReservationRepository",
|
||||||
|
"SyncJobRepository",
|
||||||
|
"SyncLogRepository",
|
||||||
|
"TechnicalUserRepository",
|
||||||
|
]
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import uuid
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import delete, func, select, update
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.domain.entities import APIClientEntity, APIKeyEntity
|
||||||
|
from src.domain.interfaces import IAPIClientRepository, IAPIKeyRepository
|
||||||
|
from src.infrastructure.database.models import APIClientModel, APIKeyModel
|
||||||
|
|
||||||
|
|
||||||
|
class APIClientRepository(IAPIClientRepository):
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
async def get_by_id(self, client_id: uuid.UUID) -> APIClientEntity | None:
|
||||||
|
stmt = select(APIClientModel).where(APIClientModel.id == client_id)
|
||||||
|
result = await self._session.execute(stmt)
|
||||||
|
m = result.scalar_one_or_none()
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
return APIClientEntity(
|
||||||
|
id=m.id, name=m.name, description=m.description, owner_id=m.owner_id,
|
||||||
|
is_active=m.is_active, scopes=m.scopes or [], rate_limit=m.rate_limit,
|
||||||
|
allowed_ips=m.allowed_ips or [], metadata_=m.metadata_, created_at=m.created_at, updated_at=m.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def list_clients(self, *, is_active: bool | None = None, offset: int = 0, limit: int = 50) -> tuple[Sequence[APIClientEntity], int]:
|
||||||
|
filters: list[Any] = []
|
||||||
|
if is_active is not None:
|
||||||
|
filters.append(APIClientModel.is_active == is_active)
|
||||||
|
count_stmt = select(func.count()).select_from(APIClientModel)
|
||||||
|
if filters:
|
||||||
|
count_stmt = count_stmt.where(*filters)
|
||||||
|
total = (await self._session.execute(count_stmt)).scalar() or 0
|
||||||
|
stmt = select(APIClientModel).order_by(APIClientModel.name).offset(offset).limit(limit)
|
||||||
|
if filters:
|
||||||
|
stmt = stmt.where(*filters)
|
||||||
|
result = await self._session.execute(stmt)
|
||||||
|
models = result.scalars().all()
|
||||||
|
entities = [
|
||||||
|
APIClientEntity(id=m.id, name=m.name, description=m.description, owner_id=m.owner_id,
|
||||||
|
is_active=m.is_active, scopes=m.scopes or [], rate_limit=m.rate_limit,
|
||||||
|
allowed_ips=m.allowed_ips or [], metadata_=m.metadata_, created_at=m.created_at, updated_at=m.updated_at)
|
||||||
|
for m in models
|
||||||
|
]
|
||||||
|
return entities, total
|
||||||
|
|
||||||
|
async def create(self, entity: APIClientEntity) -> APIClientEntity:
|
||||||
|
model = APIClientModel(
|
||||||
|
name=entity.name, description=entity.description, owner_id=entity.owner_id,
|
||||||
|
is_active=entity.is_active, scopes=entity.scopes, rate_limit=entity.rate_limit,
|
||||||
|
allowed_ips=entity.allowed_ips, metadata_=entity.metadata_,
|
||||||
|
)
|
||||||
|
self._session.add(model)
|
||||||
|
await self._session.flush()
|
||||||
|
return await self.get_by_id(model.id) # type: ignore[return-value]
|
||||||
|
|
||||||
|
async def update(self, entity: APIClientEntity) -> APIClientEntity:
|
||||||
|
stmt = update(APIClientModel).where(APIClientModel.id == entity.id).values(
|
||||||
|
name=entity.name, description=entity.description, is_active=entity.is_active,
|
||||||
|
scopes=entity.scopes, rate_limit=entity.rate_limit, allowed_ips=entity.allowed_ips,
|
||||||
|
metadata_=entity.metadata_,
|
||||||
|
)
|
||||||
|
await self._session.execute(stmt)
|
||||||
|
await self._session.flush()
|
||||||
|
return await self.get_by_id(entity.id) # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
|
class APIKeyRepository(IAPIKeyRepository):
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
async def get_by_prefix(self, key_prefix: str) -> APIKeyEntity | None:
|
||||||
|
stmt = select(APIKeyModel).where(APIKeyModel.key_prefix == key_prefix, APIKeyModel.is_active == True) # noqa: E712
|
||||||
|
result = await self._session.execute(stmt)
|
||||||
|
m = result.scalar_one_or_none()
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
return APIKeyEntity(
|
||||||
|
id=m.id, api_client_id=m.api_client_id, key_prefix=m.key_prefix,
|
||||||
|
key_hash=m.key_hash, name=m.name, is_active=m.is_active,
|
||||||
|
expires_at=m.expires_at, last_used_at=m.last_used_at, created_at=m.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def list_by_client(self, api_client_id: uuid.UUID) -> Sequence[APIKeyEntity]:
|
||||||
|
stmt = select(APIKeyModel).where(APIKeyModel.api_client_id == api_client_id).order_by(APIKeyModel.created_at.desc())
|
||||||
|
result = await self._session.execute(stmt)
|
||||||
|
models = result.scalars().all()
|
||||||
|
return [
|
||||||
|
APIKeyEntity(
|
||||||
|
id=m.id, api_client_id=m.api_client_id, key_prefix=m.key_prefix,
|
||||||
|
key_hash=m.key_hash, name=m.name, is_active=m.is_active,
|
||||||
|
expires_at=m.expires_at, last_used_at=m.last_used_at, created_at=m.created_at,
|
||||||
|
)
|
||||||
|
for m in models
|
||||||
|
]
|
||||||
|
|
||||||
|
async def create(self, entity: APIKeyEntity) -> APIKeyEntity:
|
||||||
|
model = APIKeyModel(
|
||||||
|
api_client_id=entity.api_client_id, key_prefix=entity.key_prefix,
|
||||||
|
key_hash=entity.key_hash, name=entity.name, is_active=entity.is_active,
|
||||||
|
expires_at=entity.expires_at,
|
||||||
|
)
|
||||||
|
self._session.add(model)
|
||||||
|
await self._session.flush()
|
||||||
|
return APIKeyEntity(
|
||||||
|
id=model.id, api_client_id=model.api_client_id, key_prefix=model.key_prefix,
|
||||||
|
key_hash=model.key_hash, name=model.name, is_active=model.is_active,
|
||||||
|
expires_at=model.expires_at, created_at=model.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def update_last_used(self, key_id: uuid.UUID) -> None:
|
||||||
|
stmt = update(APIKeyModel).where(APIKeyModel.id == key_id).values(last_used_at=func.now())
|
||||||
|
await self._session.execute(stmt)
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user